Skip to content

Commit 0ab94a8

Browse files
authored
fix: support KR stock autocomplete suggestions (#1723)
* fix: support KR stock autocomplete suggestions * fix(review-feedback-1723): 补充自动补全 JP/KR 建议渲染截图、前后对比或无法截图说明 * fix(review-feedback-1723): 补充 Web 自动补全 JP/KR 建议渲染的截图或无法截图说明 * fix(review-feedback-1723): docs/review-evidence-kr-autocomplete-ui.md 是针对本 PR * fix(review-feedback-1723): 修正 PR 描述与当前 head 的实际改动、验证范围不一致问题 * fix(review-feedback-1723): 修正 PR 描述与当前 diff 的实质性不一致,避免合入记录误导后续维护者 * fix(review-feedback-1723): 收敛 PR 描述与实际 diff 的契约不一致,避免用户误以为裸 005930 已会解析为 005930.KS * fix(review-feedback-1723): 把描述、验证证据和兼容性说明收敛到当前 6 个改动文件真实范围,或补齐描述中声称的后端/历史链路实现与测试 * fix(review-feedback-1723): 收敛 PR 描述与实际 diff,避免把未实现的 bare-code/历史展示能力作为本 PR 已交付内容合入 * fix(review-feedback-1723): 补上相应真实代码与测试后再审 * fix(review-feedback-1723): 补齐所声称的后端实现与测试后再评审 * fix(review-feedback-1723): PR description 与当前完整 diff 不一致:描述声称新增 bare JP/KR code * fix(review-feedback-1723): PR 描述与当前完整 diff 不一致 * fix(review-feedback-1723): 补充 Refs 1718,方便后续追踪 * fix(review-feedback-1723): 收敛描述与验证证据 * fix: resolve JP KR bare stock codes * fix: backfill JP KR daily analysis context * fix(review-feedback-1723): preserve persisted phase timing * fix: align JP KR history lookup keys * fix(review-feedback-1723): preserve raw code for snapshot lookups * fix(review-feedback-1723): 收敛
1 parent bc673b0 commit 0ab94a8

24 files changed

Lines changed: 1044 additions & 69 deletions

api/v1/endpoints/analysis.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
)
5656
from api.v1.schemas.run_flow import RunFlowSnapshot
5757
from data_provider.base import canonical_stock_code, normalize_stock_code
58+
from src.data.stock_index_loader import resolve_index_stock_code
5859
from src.config import Config
5960
from src.core.market_review_lock import (
6061
MarketReviewExecutionLock as _MarketReviewExecutionLock,
@@ -69,11 +70,14 @@
6970
extract_analysis_context_pack_overview,
7071
sanitize_context_snapshot_for_api,
7172
)
72-
from src.market_phase_summary import extract_market_phase_summary, render_market_phase_summary
73+
from src.market_phase_summary import (
74+
extract_market_phase_summary,
75+
rebuild_market_phase_summary_for_stock_code,
76+
)
77+
from src.services.stock_code_utils import is_code_like, resolve_index_stock_code_for_analysis
7378
from src.report_language import get_localized_stock_name, normalize_report_language
7479
from src.schemas.decision_action import build_action_fields
7580
from src.services.name_to_code_resolver import resolve_name_to_code
76-
from src.services.stock_code_utils import is_code_like
7781
from src.services.task_queue import (
7882
get_task_queue,
7983
DuplicateTaskError,
@@ -199,7 +203,7 @@ def _resolve_and_normalize_input(raw_value: str) -> str:
199203
return ""
200204

201205
if is_code_like(text):
202-
return canonical_stock_code(text)
206+
return resolve_index_stock_code_for_analysis(text)
203207

204208
if _is_obviously_invalid_analysis_input(text):
205209
raise _invalid_analysis_input_error()
@@ -818,6 +822,20 @@ def _extract_report_created_at(payload: Dict[str, Any]) -> Optional[str]:
818822
return _datetime_to_iso(meta.get("created_at"))
819823

820824

825+
def _display_stock_code_from_index(stock_code: Any) -> str:
826+
code = str(stock_code or "").strip()
827+
if not code:
828+
return code
829+
return resolve_index_stock_code(code) or code
830+
831+
832+
def _display_market_phase_summary(stock_code: Any, context_snapshot: Any) -> Any:
833+
return rebuild_market_phase_summary_for_stock_code(
834+
_display_stock_code_from_index(stock_code),
835+
context_snapshot,
836+
)
837+
838+
821839
def _prepare_report_for_task_enrichment(
822840
report_data: Dict[str, Any],
823841
created_at: Optional[str],
@@ -866,6 +884,9 @@ def _build_task_analysis_result(task: Any) -> AnalysisResultResponse:
866884
payload["trace_id"] = _get_task_trace_id(task) or task.task_id
867885
if not payload.get("stock_code"):
868886
payload["stock_code"] = task.stock_code
887+
display_stock_code = _display_stock_code_from_index(payload.get("stock_code"))
888+
if display_stock_code:
889+
payload["stock_code"] = display_stock_code
869890

870891
if not payload.get("stock_name") and getattr(task, "stock_name", None):
871892
payload["stock_name"] = task.stock_name
@@ -911,6 +932,14 @@ def _build_task_analysis_result(task: Any) -> AnalysisResultResponse:
911932
)
912933

913934
if not report_enriched and isinstance(report_data, dict):
935+
meta = report_data.get("meta")
936+
if isinstance(meta, dict) and display_stock_code:
937+
raw_meta_code = meta.get("stock_code") or getattr(task, "stock_code", None)
938+
meta["stock_code"] = display_stock_code
939+
meta["market_phase_summary"] = _display_market_phase_summary(
940+
raw_meta_code,
941+
{"market_phase_summary": meta.get("market_phase_summary")},
942+
)
914943
payload["report"] = _ensure_report_action_fields(report_data)
915944

916945
return AnalysisResultResponse.model_validate(payload)
@@ -1030,12 +1059,13 @@ def get_analysis_status(task_id: str) -> TaskStatus:
10301059
(raw_result or {}).get("report_language") if isinstance(raw_result, dict) else None
10311060
)
10321061
stock_name = get_localized_stock_name(record.name, record.code, report_language)
1062+
display_stock_code = _display_stock_code_from_index(record.code)
10331063

10341064
# Extract current_price / change_pct from context_snapshot
10351065
skills = None
10361066
context_snapshot = parse_json_field(getattr(record, 'context_snapshot', None))
10371067
analysis_context_pack_overview = extract_analysis_context_pack_overview(context_snapshot)
1038-
market_phase_summary = extract_market_phase_summary(context_snapshot)
1068+
market_phase_summary = _display_market_phase_summary(record.code, context_snapshot)
10391069
api_context_snapshot = sanitize_context_snapshot_for_api(context_snapshot)
10401070
if context_snapshot and isinstance(context_snapshot, dict):
10411071
raw_skills = context_snapshot.get("skills")
@@ -1083,7 +1113,7 @@ def get_analysis_status(task_id: str) -> TaskStatus:
10831113
meta=ReportMeta(
10841114
id=record.id,
10851115
query_id=task_id,
1086-
stock_code=record.code,
1116+
stock_code=display_stock_code,
10871117
stock_name=stock_name,
10881118
report_type=getattr(record, 'report_type', None),
10891119
report_language=report_language,
@@ -1117,15 +1147,15 @@ def get_analysis_status(task_id: str) -> TaskStatus:
11171147
result=AnalysisResultResponse(
11181148
query_id=task_id,
11191149
trace_id=task_id,
1120-
stock_code=record.code,
1150+
stock_code=display_stock_code,
11211151
stock_name=stock_name,
11221152
report=report_dict,
11231153
diagnostic_summary=build_run_diagnostic_summary(
11241154
context_snapshot=context_snapshot,
11251155
raw_result=raw_result,
11261156
report_saved=True,
11271157
query_id=task_id,
1128-
stock_code=record.code,
1158+
stock_code=display_stock_code,
11291159
),
11301160
created_at=record.created_at.isoformat() if record.created_at else datetime.now().isoformat()
11311161
),
@@ -1215,9 +1245,10 @@ def _build_analysis_report(
12151245
or (context_snapshot or {}).get("report_language")
12161246
or getattr(Config.get_instance(), "report_language", "zh")
12171247
)
1248+
display_stock_code = _display_stock_code_from_index(meta_data.get("stock_code", stock_code))
12181249
localized_stock_name = get_localized_stock_name(
12191250
meta_data.get("stock_name", stock_name),
1220-
meta_data.get("stock_code", stock_code),
1251+
display_stock_code,
12211252
report_language,
12221253
)
12231254
realtime_fields = extract_realtime_detail_fields(context_snapshot)
@@ -1227,15 +1258,19 @@ def _build_analysis_report(
12271258
change_pct = meta_data.get("change_pct")
12281259
if change_pct is None:
12291260
change_pct = realtime_fields.get("change_pct")
1230-
market_phase_summary = extract_market_phase_summary(context_snapshot)
1261+
raw_stock_code = meta_data.get("stock_code", stock_code)
1262+
market_phase_summary = _display_market_phase_summary(raw_stock_code, context_snapshot)
12311263
if market_phase_summary is None:
12321264
meta_phase_summary = meta_data.get("market_phase_summary")
12331265
if meta_phase_summary is not None:
1234-
market_phase_summary = render_market_phase_summary(meta_phase_summary)
1266+
market_phase_summary = _display_market_phase_summary(
1267+
raw_stock_code,
1268+
{"market_phase_summary": meta_phase_summary},
1269+
)
12351270

12361271
meta = ReportMeta(
12371272
query_id=meta_data.get("query_id", query_id),
1238-
stock_code=meta_data.get("stock_code", stock_code),
1273+
stock_code=display_stock_code,
12391274
stock_name=localized_stock_name,
12401275
report_type=meta_data.get("report_type", "detailed"),
12411276
report_language=report_language,

api/v1/endpoints/history.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ def get_stock_bar(
259259
from datetime import date as date_type
260260
from src.utils.data_processing import parse_json_field
261261

262+
service = HistoryService(db_manager)
262263
start = date_type.fromisoformat(start_date) if start_date else None
263264
end = date_type.fromisoformat(end_date) if end_date else None
264265

@@ -274,7 +275,8 @@ def get_stock_bar(
274275
# Deduplicate by normalized code, keeping the record with highest id
275276
seen: dict = {}
276277
for record in records:
277-
norm_code = _normalize_code_for_grouping(record.code or "")
278+
display_code = service._display_stock_code(record.code or "")
279+
norm_code = _normalize_code_for_grouping(display_code)
278280
if norm_code not in seen or record.id > seen[norm_code].id:
279281
seen[norm_code] = record
280282

@@ -295,16 +297,15 @@ def get_stock_bar(
295297
),
296298
)
297299

300+
display_stock_code = service._display_stock_code(record.code)
298301
analysis_count = db_manager.get_analysis_history_paginated(
299-
code=HistoryService._history_code_filter_candidates(
300-
record.code or "",
301-
),
302+
code=HistoryService._history_code_filter_candidates(display_stock_code),
302303
limit=1,
303304
)[1]
304305
items.append(
305306
StockBarItem(
306307
id=record.id,
307-
stock_code=record.code or "",
308+
stock_code=display_stock_code,
308309
stock_name=record.name,
309310
report_type=record.report_type,
310311
sentiment_score=record.sentiment_score,
@@ -316,7 +317,10 @@ def get_stock_bar(
316317
record.created_at.isoformat() if record.created_at else None
317318
),
318319
model_used=normalize_model_used(model_used),
319-
market_phase_summary=extract_market_phase_summary(getattr(record, "context_snapshot", None)),
320+
market_phase_summary=service._display_market_phase_summary(
321+
record.code,
322+
getattr(record, "context_snapshot", None),
323+
),
320324
)
321325
)
322326

@@ -385,7 +389,9 @@ def get_history_detail(
385389
# 同时不混用 `change_60d`(60 日累计涨跌幅)作为日内 change_pct 的兜底。
386390
context_snapshot = result.get("context_snapshot")
387391
analysis_context_pack_overview = extract_analysis_context_pack_overview(context_snapshot)
388-
market_phase_summary = extract_market_phase_summary(context_snapshot)
392+
market_phase_summary = result.get("market_phase_summary")
393+
if market_phase_summary is None:
394+
market_phase_summary = extract_market_phase_summary(context_snapshot)
389395
api_context_snapshot = sanitize_context_snapshot_for_api(context_snapshot)
390396
realtime_fields = extract_realtime_detail_fields(context_snapshot)
391397
current_price = realtime_fields.get("current_price")
@@ -453,7 +459,7 @@ def get_history_detail(
453459

454460
fallback_fundamental = db_manager.get_latest_fundamental_snapshot(
455461
query_id=result.get("query_id", ""),
456-
code=result.get("stock_code", ""),
462+
code=result.get("storage_stock_code") or result.get("stock_code", ""),
457463
)
458464
extracted_fundamental = extract_fundamental_detail_fields(
459465
context_snapshot=result.get("context_snapshot"),

apps/dsa-web/src/components/StockAutocomplete/SuggestionsList.tsx

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
/**
2-
* SuggestionsList Component
3-
*
4-
* Stock search suggestion list
5-
* Displays matched stock options
2+
* Stock search suggestion list.
63
*/
74

85
import type { CSSProperties } from 'react';
@@ -42,7 +39,7 @@ export function SuggestionsList({
4239
...style,
4340
backgroundColor: 'hsl(var(--card) / 0.85)',
4441
borderColor: 'var(--border-accent)',
45-
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3), -4px 0 15px -3px rgba(0, 0, 0, 0.2), 4px 0 15px -3px rgba(0, 0, 0, 0.2)'
42+
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3), -4px 0 15px -3px rgba(0, 0, 0, 0.2), 4px 0 15px -3px rgba(0, 0, 0, 0.2)',
4643
}}
4744
role="listbox"
4845
>
@@ -52,18 +49,16 @@ export function SuggestionsList({
5249
role="option"
5350
aria-selected={index === highlightedIndex}
5451
className={cn(
55-
"px-4 py-1 cursor-pointer flex items-center justify-between",
56-
"hover:bg-[var(--autocomplete-hover-bg)]/25",
57-
index === highlightedIndex && "bg-[var(--autocomplete-hover-bg)]/25"
52+
'px-4 py-1 cursor-pointer flex items-center justify-between',
53+
'hover:bg-[var(--autocomplete-hover-bg)]/25',
54+
index === highlightedIndex && 'bg-[var(--autocomplete-hover-bg)]/25',
5855
)}
5956
onClick={() => onSelect(suggestion)}
6057
onMouseEnter={() => onMouseEnter(index)}
6158
>
6259
<div className="flex items-center gap-3">
63-
{/* Market badge */}
6460
<MarketBadge market={suggestion.market} />
6561

66-
{/* Name and code */}
6762
<div className="flex flex-col">
6863
<span className="text-sm font-medium text-primary-text">
6964
{suggestion.nameZh}
@@ -74,19 +69,19 @@ export function SuggestionsList({
7469
</div>
7570
</div>
7671

77-
{/* Match type badge */}
7872
<MatchTypeBadge matchType={suggestion.matchType} />
7973
</li>
8074
))}
8175
</ul>
8276
);
8377
}
8478

85-
// Helper component: Market badge
8679
const MARKET_BADGE_CONFIG = {
8780
CN: { label: 'A股', className: 'border-danger/25 bg-danger/10 text-danger' },
8881
HK: { label: '港股', className: 'border-success/25 bg-success/10 text-success' },
8982
US: { label: '美股', className: 'border-cyan/25 bg-cyan/10 text-cyan' },
83+
JP: { label: '日股', className: 'border-indigo-500/25 bg-indigo-500/10 text-indigo-500' },
84+
KR: { label: '韩股', className: 'border-rose-500/25 bg-rose-500/10 text-rose-500' },
9085
INDEX: { label: '指数', className: 'border-purple/25 bg-purple/10 text-purple' },
9186
ETF: { label: 'ETF', className: 'border-warning/25 bg-warning/10 text-warning' },
9287
BSE: { label: '北交所', className: 'border-orange-500/25 bg-orange-500/10 text-orange-500' },
@@ -100,13 +95,12 @@ function MarketBadge({ market }: { market: string }) {
10095
}
10196

10297
return (
103-
<Badge variant="default" size="sm" className={cn("min-w-[3rem] justify-center shadow-none", config.className)}>
98+
<Badge variant="default" size="sm" className={cn('min-w-[3rem] justify-center shadow-none', config.className)}>
10499
{config.label}
105100
</Badge>
106101
);
107102
}
108103

109-
// Helper component: Match type badge
110104
function MatchTypeBadge({ matchType }: { matchType: string }) {
111105
const configMap = {
112106
exact: { label: '精确', className: 'border-cyan/25 bg-cyan/10 text-cyan' },
@@ -118,7 +112,7 @@ function MatchTypeBadge({ matchType }: { matchType: string }) {
118112
const config = configMap[matchType as keyof typeof configMap] || configMap.fuzzy;
119113

120114
return (
121-
<Badge variant="default" size="sm" className={cn("shrink-0 shadow-none", config.className)}>
115+
<Badge variant="default" size="sm" className={cn('shrink-0 shadow-none', config.className)}>
122116
{config.label}
123117
</Badge>
124118
);

0 commit comments

Comments
 (0)