From 043c60378ec2603c51871e2217591e5a2e5aa7f7 Mon Sep 17 00:00:00 2001 From: HyunRyeol Park Date: Tue, 30 Jun 2026 20:02:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=A5=E5=91=8A=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=96=B0=E5=A2=9E=E9=9F=A9=E8=AF=AD=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20(Korean=20report=20language,=20ko)=20(#1614)=20(#18?= =?UTF-8?q?44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(i18n): add Korean to report language label maps Extend the report language layer with a third code `ko` so reports can render deterministic labels in Korean. Adds `ko` to the supported list, aliases (korean/kr/ko-kr), every translation map, the full report label set, sentiment bands, the config registry enum and `.env.example`. `zh`/`en` behavior is unchanged and unknown languages still fall back to the default. Prompt and Web surfaces follow in later changes. Refs #1614 * feat(i18n): emit Korean output directives in analysis prompts Make the analysis and market-review prompts produce Korean output when REPORT_LANGUAGE=ko. The decision agent now appends a Korean output-language directive (JSON keys and decision_type enum unchanged), and the market-review prompt reuses the English structural scaffolding while instructing the model to write the shell, headings and conclusion in Korean. Market-phase and context-pack prompt sections route ko to the English structural base. The market-review payload keeps the truthful language code via a dedicated output-language helper, leaving zh/en behavior unchanged. Refs #1614 * fix(i18n): localize phase and market-context guardrails for Korean Route Korean reports through the English structural scaffolding for market-phase and market-context prompt sections, and add Korean output, detection markers, negations and recap patterns to the phase-decision and daily-market-context guardrails so they operate on Korean model output. Confidence and operation labels now flow through the shared localize helpers. zh/en behavior is unchanged. Refs #1614 * feat(i18n): localize Korean fallback output across analysis pipeline Add Korean output to the deterministic strings emitted outside the LLM: per-stock and executor output-language directives, no-API-key / backend / parse error fallbacks, hold-watch advice and reasons, market-review titles and summaries, and history and notification report labels. Fund flow, trend and confidence values now flow through the shared localize helpers, and language-keyed advice tables no longer raise KeyError for a third language. Structural prompt sections route ko to the English scaffolding. zh/en output is unchanged. Refs #1614 * feat(web): add Korean report language rendering Extend the Web ReportLanguage type with ko and add Korean copy to the report detail surfaces: report text, sentiment labels, market-phase labels, analysis-context summary, market-review view, diagnostics and news source. Run-flow chrome that is keyed by UI language falls back to English for ko. The report-language selector is driven by the backend config schema, so Korean appears automatically. zh/en rendering is unchanged. Refs #1614 * docs(i18n): document Korean report language support Note that REPORT_LANGUAGE accepts ko in the bilingual guides and the analyze / market-review request-language parameters, and add a CHANGELOG entry for Korean report output. Refs #1614 * fix(i18n): canonicalize Korean values and accept ko in API schemas Add Korean aliases to the operation-advice, trend, confidence, chip and bias canonical maps so Korean model output (매수/매도/보유/관망 etc.) resolves to the correct decision_type and signal level instead of falling back to hold or a score-band signal. Accept ko in the analyze, market-review and decision-signal request schemas (and the static API spec) so the typed client and backend agree and per-request Korean analysis is not rejected with 422. Refs #1614 --- .env.example | 2 +- api/v1/schemas/analysis.py | 4 +- api/v1/schemas/decision_signals.py | 2 +- .../report/AnalysisContextSummary.tsx | 50 +++- .../report/MarketReviewReportView.tsx | 25 +- .../components/report/ReportDiagnostics.tsx | 32 ++- .../src/components/report/ReportNews.tsx | 4 + apps/dsa-web/src/types/analysis.ts | 16 +- .../utils/__tests__/reportLanguage.test.ts | 32 +++ apps/dsa-web/src/utils/marketPhase.ts | 20 ++ apps/dsa-web/src/utils/reportLanguage.ts | 48 +++- docs/CHANGELOG.md | 1 + docs/architecture/api_spec.json | 3 +- docs/full-guide.md | 8 +- docs/full-guide_EN.md | 8 +- src/agent/agents/decision_agent.py | 10 + src/agent/executor.py | 2 + src/analysis_context_pack_prompt.py | 4 +- src/analyzer.py | 222 +++++++++++++----- src/core/config_registry.py | 3 +- src/core/market_review.py | 27 ++- src/core/pipeline.py | 32 ++- src/daily_market_context_guardrail.py | 83 +++++-- src/market_analyzer.py | 20 +- src/market_context.py | 4 +- src/market_phase_prompt.py | 4 +- src/market_phase_summary.py | 6 +- src/notification.py | 20 +- src/phase_decision_guardrail.py | 139 ++++++++--- src/report_language.py | 204 ++++++++++++++-- src/services/daily_market_context.py | 2 +- src/services/history_service.py | 24 +- tests/test_api_schema_pydantic.py | 14 ++ tests/test_report_language.py | 60 +++++ tests/test_report_language_fallbacks_ko.py | 32 +++ .../test_report_language_prompt_directives.py | 66 ++++++ tests/test_system_config_service.py | 9 +- 37 files changed, 1049 insertions(+), 193 deletions(-) create mode 100644 apps/dsa-web/src/utils/__tests__/reportLanguage.test.ts create mode 100644 tests/test_report_language_fallbacks_ko.py create mode 100644 tests/test_report_language_prompt_directives.py diff --git a/.env.example b/.env.example index 80f86019d..415cf5f17 100644 --- a/.env.example +++ b/.env.example @@ -668,7 +668,7 @@ AGENT_SKILLS= # 报告类型:simple(精简)、full(完整)、brief(3-5句概括) # Docker环境下如果推送内容不完整,可以设置为 full # REPORT_TYPE=simple -# 报告输出语言:zh(中文,默认) / en(英文) +# 报告输出语言:zh(中文,默认) / en(英文) / ko(韩文) # REPORT_LANGUAGE=zh # 仅分析结果摘要:设为 true 时只推送汇总,不含个股详情 # REPORT_SUMMARY_ONLY=false diff --git a/api/v1/schemas/analysis.py b/api/v1/schemas/analysis.py index bdb6e8e43..90119183a 100644 --- a/api/v1/schemas/analysis.py +++ b/api/v1/schemas/analysis.py @@ -80,7 +80,7 @@ class AnalyzeRequest(BaseModel): True, description="是否发送推送通知(Telegram/企业微信等)" ) - report_language: Optional[Literal["zh", "en"]] = Field( + report_language: Optional[Literal["zh", "en", "ko"]] = Field( None, validation_alias=AliasChoices("report_language", "reportLanguage"), description="本次分析报告输出语言;未传时使用全局 REPORT_LANGUAGE", @@ -116,7 +116,7 @@ class MarketReviewRequest(BaseModel): True, description="是否在大盘复盘完成后发送推送通知", ) - report_language: Optional[Literal["zh", "en"]] = Field( + report_language: Optional[Literal["zh", "en", "ko"]] = Field( None, validation_alias=AliasChoices("report_language", "reportLanguage"), description="本次大盘复盘报告输出语言;未传时使用全局 REPORT_LANGUAGE", diff --git a/api/v1/schemas/decision_signals.py b/api/v1/schemas/decision_signals.py index e3cb2234e..7666d67f5 100644 --- a/api/v1/schemas/decision_signals.py +++ b/api/v1/schemas/decision_signals.py @@ -53,7 +53,7 @@ class DecisionSignalCreateRequest(BaseModel): status: Optional[DecisionSignalStatus] = None expires_at: Optional[datetime] = None metadata: Optional[Dict[str, Any]] = None - report_language: Optional[Literal["zh", "en"]] = None + report_language: Optional[Literal["zh", "en", "ko"]] = None class DecisionSignalStatusUpdateRequest(BaseModel): diff --git a/apps/dsa-web/src/components/report/AnalysisContextSummary.tsx b/apps/dsa-web/src/components/report/AnalysisContextSummary.tsx index 77241a566..5c41c40d9 100644 --- a/apps/dsa-web/src/components/report/AnalysisContextSummary.tsx +++ b/apps/dsa-web/src/components/report/AnalysisContextSummary.tsx @@ -52,6 +52,14 @@ const BLOCK_LABELS: Record> = { fundamentals: 'fundamentals', chip: 'chip', }, + ko: { + quote: '시세', + daily_bars: '일봉', + technical: '기술', + news: '뉴스', + fundamentals: '펀더멘털', + chip: '매물대', + }, }; const TEXT = { @@ -115,6 +123,36 @@ const TEXT = { fetch_failed: 'Fetch failed', }, }, + ko: { + eyebrow: '데이터 컨텍스트', + title: '입력 데이터 블록', + counts: '상태 카운트', + source: '출처', + warnings: '경고', + missingReasons: '누락 사유', + inputScope: '이번 분석 입력', + evidenceScope: '이번 LLM 입력에 포함된 항목만 표시하며, 데이터 소스 실행 성공과는 다릅니다', + qualityScore: '품질 점수', + limitations: '데이터 한계', + newsResultCount: '뉴스 결과 수', + triggerSource: '트리거', + qualityLevel: { + good: '양호', + usable: '사용 가능', + limited: '제한적', + poor: '미흡', + }, + status: { + available: '사용 가능', + missing: '누락', + not_supported: '미지원', + fallback: '강등', + stale: '만료', + estimated: '추정', + partial: '부분 사용', + fetch_failed: '수집 실패', + }, + }, } as const; const MISSING_REASON_LABELS: Record> = { @@ -138,6 +176,16 @@ const MISSING_REASON_LABELS: Record> = { today_missing: 'Today data not included in analysis input', yesterday_missing: 'Yesterday data not included in analysis input', }, + ko: { + daily_bars_missing: '분석 입력에 포함되지 않음', + news_context_missing: '분석 입력에 포함되지 않음', + realtime_quote_missing: '분석 입력에 포함되지 않음', + trend_result_missing: '분석 입력에 포함되지 않음', + fundamental_context_missing: '분석 입력에 포함되지 않음', + chip_distribution_missing: '분석 입력에 포함되지 않음', + today_missing: '당일 데이터가 분석 입력에 포함되지 않음', + yesterday_missing: '전일 데이터가 분석 입력에 포함되지 않음', + }, }; const STATUS_ORDER: AnalysisContextPackBlockStatus[] = [ @@ -167,7 +215,7 @@ const getCount = ( const formatLimitation = ( value: string, language: ReportLanguage, - text: typeof TEXT.zh | typeof TEXT.en, + text: (typeof TEXT)[ReportLanguage], ): string => { const [rawKey, ...statusParts] = value.split(':'); if (!rawKey || statusParts.length === 0) { diff --git a/apps/dsa-web/src/components/report/MarketReviewReportView.tsx b/apps/dsa-web/src/components/report/MarketReviewReportView.tsx index 8ad8653d1..087107290 100644 --- a/apps/dsa-web/src/components/report/MarketReviewReportView.tsx +++ b/apps/dsa-web/src/components/report/MarketReviewReportView.tsx @@ -276,6 +276,29 @@ const MARKET_REVIEW_TEXT: Record { @@ -298,7 +321,7 @@ export const MarketReviewReportView: React.FC = ({ }) => { const normalizedReportLanguage = normalizeReportLanguage(reportLanguage); const text = getReportText(normalizedReportLanguage); - const runFlowText = UI_TEXT[normalizedReportLanguage]; + const runFlowText = UI_TEXT[normalizedReportLanguage === 'ko' ? 'en' : normalizedReportLanguage]; const marketReviewText = MARKET_REVIEW_TEXT[normalizedReportLanguage]; const [loadedMarkdown, setLoadedMarkdown] = useState(null); const [loadError, setLoadError] = useState(null); diff --git a/apps/dsa-web/src/components/report/ReportDiagnostics.tsx b/apps/dsa-web/src/components/report/ReportDiagnostics.tsx index 9d47d6c8a..cb4e211ae 100644 --- a/apps/dsa-web/src/components/report/ReportDiagnostics.tsx +++ b/apps/dsa-web/src/components/report/ReportDiagnostics.tsx @@ -93,6 +93,36 @@ const TEXT = { skipped: 'Skipped', }, }, + ko: { + eyebrow: '실행 진단', + title: '실행 상태', + loading: '진단 불러오는 중...', + unavailable: '실행 진단을 사용할 수 없음', + noComponents: '컴포넌트 진단 없음', + components: '핵심 경로', + advanced: '고급 필드', + copy: '진단 정보 복사', + copied: '복사됨', + scope: '수집 / LLM / 저장 / 알림 경로', + trace: 'Trace', + task: 'Task', + query: 'Query', + trigger: '트리거', + overall: { + normal: '정상', + degraded: '부분 강등', + failed: '실패', + unknown: '알 수 없음', + }, + component: { + ok: '정상', + degraded: '최근 실패 후 강등', + failed: '실패', + unknown: '알 수 없음', + not_configured: '미설정', + skipped: '건너뜀', + }, + }, } as const; const OVERALL_STATUS_STYLE: Record = { @@ -140,7 +170,7 @@ export const ReportDiagnostics: React.FC = ({ }) => { const reportLanguage = normalizeReportLanguage(language); const text = TEXT[reportLanguage]; - const runFlowText = UI_TEXT[reportLanguage]; + const runFlowText = UI_TEXT[reportLanguage === 'ko' ? 'en' : reportLanguage]; const [fetchState, setFetchState] = useState<{ recordId?: number; summary: RunDiagnosticSummary | null; diff --git a/apps/dsa-web/src/components/report/ReportNews.tsx b/apps/dsa-web/src/components/report/ReportNews.tsx index 2dceb6f83..01b154b87 100644 --- a/apps/dsa-web/src/components/report/ReportNews.tsx +++ b/apps/dsa-web/src/components/report/ReportNews.tsx @@ -23,6 +23,10 @@ const NEWS_SOURCE_TEXT = { sourceLabel: 'Related news / follow-up retrieval', sourceHint: 'Source: supplemental report-page news; analysis input is shown in Input Blocks.', }, + ko: { + sourceLabel: '관련 뉴스 / 후속 검색', + sourceHint: '출처: 리포트 페이지 보충 뉴스이며, 분석 사용 여부는 입력 데이터 블록 기준입니다.', + }, } as const; /** diff --git a/apps/dsa-web/src/types/analysis.ts b/apps/dsa-web/src/types/analysis.ts index 722235b92..ed2ce2f8a 100644 --- a/apps/dsa-web/src/types/analysis.ts +++ b/apps/dsa-web/src/types/analysis.ts @@ -39,7 +39,7 @@ export interface MarketReviewAccepted { // ============ Report Types ============ -export type ReportLanguage = 'zh' | 'en'; +export type ReportLanguage = 'zh' | 'en' | 'ko'; export type MarketPhaseValue = | 'premarket' @@ -92,7 +92,12 @@ export type SentimentLabel = | 'Bearish' | 'Neutral' | 'Bullish' - | 'Very Bullish'; + | 'Very Bullish' + | '매우 비관' + | '비관' + | '중립' + | '낙관' + | '매우 낙관'; export type DecisionAction = 'buy' | 'add' | 'hold' | 'reduce' | 'sell' | 'watch' | 'avoid' | 'alert'; @@ -506,6 +511,13 @@ export const getSentimentLabel = (score: number, language: ReportLanguage = 'zh' if (score <= 80) return 'Bullish'; return 'Very Bullish'; } + if (language === 'ko') { + if (score <= 20) return '매우 비관'; + if (score <= 40) return '비관'; + if (score <= 60) return '중립'; + if (score <= 80) return '낙관'; + return '매우 낙관'; + } if (score <= 20) return '极度悲观'; if (score <= 40) return '悲观'; if (score <= 60) return '中性'; diff --git a/apps/dsa-web/src/utils/__tests__/reportLanguage.test.ts b/apps/dsa-web/src/utils/__tests__/reportLanguage.test.ts new file mode 100644 index 000000000..db1ecddd3 --- /dev/null +++ b/apps/dsa-web/src/utils/__tests__/reportLanguage.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { getReportText, normalizeReportLanguage } from '../reportLanguage'; +import { getSentimentLabel } from '../../types/analysis'; + +describe('reportLanguage ko support', () => { + it('normalizes ko and falls back to zh for unknown', () => { + expect(normalizeReportLanguage('ko')).toBe('ko'); + expect(normalizeReportLanguage('en')).toBe('en'); + expect(normalizeReportLanguage('fr')).toBe('zh'); + expect(normalizeReportLanguage(undefined)).toBe('zh'); + }); + + it('returns Korean report copy for ko', () => { + const ko = getReportText('ko'); + expect(ko.keyInsights).toBe('핵심 인사이트'); + expect(ko.actionAdvice).toBe('대응 전략'); + expect(ko.fullReport).toBe('전체 분석 리포트'); + }); + + it('keeps zh/en report copy unchanged', () => { + expect(getReportText('zh').keyInsights).toBe('核心洞察'); + expect(getReportText('en').keyInsights).toBe('KEY INSIGHTS'); + }); + + it('returns Korean sentiment labels by band', () => { + expect(getSentimentLabel(90, 'ko')).toBe('매우 낙관'); + expect(getSentimentLabel(50, 'ko')).toBe('중립'); + expect(getSentimentLabel(10, 'ko')).toBe('매우 비관'); + expect(getSentimentLabel(90, 'en')).toBe('Very Bullish'); + }); +}); diff --git a/apps/dsa-web/src/utils/marketPhase.ts b/apps/dsa-web/src/utils/marketPhase.ts index d9b72a7d0..d919f55e9 100644 --- a/apps/dsa-web/src/utils/marketPhase.ts +++ b/apps/dsa-web/src/utils/marketPhase.ts @@ -19,6 +19,12 @@ const REQUEST_PHASE_LABELS: Record intraday: 'Intraday', postmarket: 'Post-market', }, + ko: { + auto: '자동 단계', + premarket: '장 시작 전', + intraday: '장중', + postmarket: '장 마감 후', + }, }; const MARKET_PHASE_LABELS: Record> = { @@ -40,6 +46,15 @@ const MARKET_PHASE_LABELS: Record - value === 'en' ? 'en' : 'zh'; + value === 'en' ? 'en' : value === 'ko' ? 'ko' : 'zh'; const REPORT_TEXT = { zh: { @@ -96,6 +96,52 @@ const REPORT_TEXT = { neutralBoard: 'Neutral', reanalyze: 'Reanalyze', }, + ko: { + keyInsights: '핵심 인사이트', + noAnalysisSummary: '분석 결론 없음', + actionAdvice: '대응 전략', + noAdvice: '제안 없음', + trendPrediction: '추세 전망', + noPrediction: '예측 없음', + marketSentiment: '시장 심리', + strategyPoints: '전략 가격대', + sniperLevels: '대응 가격대', + idealBuy: '이상적 매수가', + secondaryBuy: '추가 매수가', + stopLoss: '손절가', + takeProfit: '목표가', + noValue: '—', + newsFeed: '뉴스 피드', + relatedNews: '관련 뉴스', + refresh: '새로고침', + retry: '다시 시도', + dismiss: '닫기', + details: '상세 보기', + loadingNews: '뉴스 불러오는 중...', + noNews: '관련 뉴스 없음', + noNewsDescription: '잠시 후 새로고침하여 최신 소식을 확인하세요.', + openLink: '열기', + transparency: '투명성', + traceability: '데이터 추적', + rawResult: '원본 분석 결과', + analysisSnapshot: '분석 스냅샷', + copy: '복사', + copied: '복사됨!', + recordId: '레코드 ID', + fullReport: '전체 분석 리포트', + loadingReport: '리포트 불러오는 중...', + loadReportFailed: '리포트 불러오기 실패', + copyMarkdownSource: 'Markdown 소스 복사', + copyPlainText: '일반 텍스트 복사', + analysisModel: '분석 모델', + fearGreedIndex: '공포·탐욕 지수', + boardLinkage: '섹터 연동', + relatedBoards: '관련 섹터', + leadingBoard: '강세', + laggingBoard: '약세', + neutralBoard: '중립', + reanalyze: '재분석', + }, } as const; export const getReportText = (language?: string | null) => REPORT_TEXT[normalizeReportLanguage(language)]; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 88269373e..432a29e48 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). +- [新功能] 报告输出语言新增韩语(`REPORT_LANGUAGE=ko`),覆盖个股报告、大盘复盘、提示词输出语言、决策护栏、通知模板标签与 Web 报告详情页文案;`ko` 复用英文结构骨架并约束模型用韩文输出,`zh`/`en` 行为保持不变 (#1614) - [修复] 修复 Web 首页个股栏在 stock-bar 摘要字段缺失或动作建议无法归类时隐藏情绪分与建议标识的问题。 diff --git a/docs/architecture/api_spec.json b/docs/architecture/api_spec.json index db87d0b45..25fcc5bf1 100644 --- a/docs/architecture/api_spec.json +++ b/docs/architecture/api_spec.json @@ -4116,7 +4116,8 @@ "type": "string", "enum": [ "zh", - "en" + "en", + "ko" ] }, { diff --git a/docs/full-guide.md b/docs/full-guide.md index 189a619c1..f82902697 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -114,7 +114,7 @@ daily_stock_analysis/ |------------|------|:----:| | `SINGLE_STOCK_NOTIFY` | 单股推送模式:设为 `true` 则每分析完一只股票立即推送 | 可选 | | `REPORT_TYPE` | 报告类型:`simple`(精简)、`full`(完整)、`brief`(3-5句概括),Docker环境推荐设为 `full` | 可选 | -| `REPORT_LANGUAGE` | 报告输出语言:`zh`(默认中文) / `en`(英文);会同步影响 Prompt、模板、通知 fallback 与 Web 报告页固定文案。仓库自带 `00-daily-analysis.yml` 已显式映射该变量,直接在 Actions Secrets/Variables 中配置即可生效 | 可选 | +| `REPORT_LANGUAGE` | 报告输出语言:`zh`(默认中文) / `en`(英文) / `ko`(韩文);会同步影响 Prompt、模板、通知 fallback 与 Web 报告页固定文案。`ko` 复用英文结构骨架并通过输出语言指令约束模型用韩文输出,通知按报告语言渲染本地化标签。仓库自带 `00-daily-analysis.yml` 已显式映射该变量,直接在 Actions Secrets/Variables 中配置即可生效 | 可选 | | `REPORT_SUMMARY_ONLY` | 仅分析结果摘要:设为 `true` 时只推送汇总,不含个股详情;多股时适合快速浏览(默认 false,Issue #262) | 可选 | | `REPORT_SHOW_LLM_MODEL` | 通知报告底部是否显示本次分析使用的 LLM 模型名称,默认 `true`;设为 `false` 可隐藏运行时模型信息。该变量仅调整展示,不影响 provider/model/Base URL、LiteLLM 路由或运行时模型保存/迁移/清理语义。 | 可选 | | `REPORT_TEMPLATES_DIR` | Jinja2 模板目录(相对项目根,默认 `templates`) | 可选 | @@ -1499,7 +1499,7 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。 - Web 语言状态采用两层机制:`dsa.uiLanguage`(浏览器持久化)与 `REPORT_LANGUAGE`(报告输出)解耦。 - `dsa.uiLanguage` 只决定 WebUI 文案与导航语言(`zh` / `en`),取值优先级为本地持久化值 -> 浏览器语言 -> 默认 `zh`。 - - `REPORT_LANGUAGE` 控制报告文本、股票简称本地化与报告页固定文案(`zh` / `en`)。 + - `REPORT_LANGUAGE` 控制报告文本、股票简称本地化与报告页固定文案(`zh` / `en` / `ko`)。 - 页面语言切换为用户体验增强,不属于回归验证证据记录范围;截图与命令请按 PR 流程在 PR 描述中单独维护。 - 本改动仅新增请求级报告语言覆盖参数,不改变 `provider`/`model`/`base_url` 的配置迁移与清理逻辑。 @@ -1543,10 +1543,10 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。 > 说明:`POST /api/v1/analysis/analyze` 在 `async_mode=false` 时仅支持单只股票;批量 `stock_codes` 需使用 `async_mode=true`。异步 `202` 响应对单股返回 `task_id`,对批量返回 `accepted` / `duplicates` 汇总结构。 > 说明:`POST /api/v1/analysis/analyze` 支持使用 `skills` 传入策略 skill ID 列表;若未传则按服务端默认策略执行。为兼容历史调用,`strategies` 字段仍作为兼容别名保留。 > 说明:`POST /api/v1/analysis/analyze` 支持 `analysis_phase=auto|premarket|intraday|postmarket`,默认 `auto`。非 `auto` 只覆盖本次分析阶段与派生阶段标记,不改写真实交易日历时间;accepted response、内存 task status、任务列表和 SSE 会回显请求阶段,最终报告阶段以 `report.meta.market_phase_summary.phase` 为准。 -> 说明:`POST /api/v1/analysis/analyze` 支持 `report_language=zh|en`,并兼容 `reportLanguage` 作为别名;未传时回退到全局 `REPORT_LANGUAGE`(或环境中的 `Config.report_language`)。该字段仅影响本次分析的报告文本、`report.meta.report_language` 与持久化展示,不会持久化为运行时配置。 +> 说明:`POST /api/v1/analysis/analyze` 支持 `report_language=zh|en|ko`,并兼容 `reportLanguage` 作为别名;未传时回退到全局 `REPORT_LANGUAGE`(或环境中的 `Config.report_language`)。该字段仅影响本次分析的报告文本、`report.meta.report_language` 与持久化展示,不会持久化为运行时配置。 > 说明:Web 侧首页策略下拉为显式可选策略入口。用户未手动选择时不会携带 `skills`,与历史客户端行为一致;选择策略后将透传到该接口并在任务状态与历史快照中保留。 > 说明:`POST /api/v1/analysis/market-review` 采用后端与 CLI/Bot 共用的配置路径(`GeminiAnalyzer(config=...)` 与同样的搜索/提示词构造入口)。Provider 兼容路由会优先识别并使用 `litellm_model`、`llm_model_list`,若未配置则回退 legacy `GEMINI_*`、`OPENAI_*`、`ANTHROPIC_*`、`DEEPSEEK_*` 键;不会新增/调整 provider、Base URL 或 LiteLLM 路由语义。 -> 说明:`POST /api/v1/analysis/market-review` 额外支持 `report_language=zh|en`(支持别名 `reportLanguage`)。未传时同样回退到全局 `REPORT_LANGUAGE`。该参数仅影响本次复盘报告文本与结构化返回字段中的语言相关内容;Bot、schedule、CLI 或按钮触发的 `main.py --market-review` 仍沿用全局配置,未新增请求级覆盖能力。 +> 说明:`POST /api/v1/analysis/market-review` 额外支持 `report_language=zh|en|ko`(支持别名 `reportLanguage`)。未传时同样回退到全局 `REPORT_LANGUAGE`。该参数仅影响本次复盘报告文本与结构化返回字段中的语言相关内容;Bot、schedule、CLI 或按钮触发的 `main.py --market-review` 仍沿用全局配置,未新增请求级覆盖能力。 > 说明:`POST /api/v1/analysis/market-review` 是 Web / 桌面端的人工触发入口,点击后会直接提交大盘复盘任务,不会因 `TRADING_DAY_CHECK_ENABLED=true` 或当日相关市场休市而短路跳过;定时任务、GitHub Actions 手动运行和 CLI 默认入口仍遵循交易日检查,可用 `--force-run` 或 workflow `force_run` 覆盖。 > 审计依据:优先级与回退语义以 `src/config.py` 的 `Config._load_from_env()` 为准(`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy)。配套回归见 `tests/test_llm_channel_config.py`(配置源解析)与 `tests/test_market_review_runtime.py`(共享装配路径)。该接口当前仅提供单进程/单机级防重复能力,若为多实例部署需通过外部任务队列或分布式锁补齐全局幂等。 > 说明:`POST /api/v1/analysis/market-review` 触发后,报告会以 `report_type=market_review` 写入历史库;你可直接查询 `/api/v1/history` 或 `/api/v1/history/{record_id}` 获取历史 Markdown,避免再次触发分析重算。 diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index 7e28b0add..2a6536a75 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -115,7 +115,7 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions` |------------|------|:----:| | `SINGLE_STOCK_NOTIFY` | Single stock push mode: set to `true` to push immediately after each stock analysis | Optional | | `REPORT_TYPE` | Report type: `simple` (concise), `full` (complete), `brief` (3-5 sentences), Docker recommended: `full` | Optional | -| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. The bundled `00-daily-analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional | +| `REPORT_LANGUAGE` | Report output language: `zh` (default Chinese) / `en` (English) / `ko` (Korean); also updates prompt instructions, templates, notification fallbacks, and fixed copy in the Web report view. `ko` reuses the English structural scaffolding and constrains the model to Korean output via an output-language directive; notifications render localized labels by report language. The bundled `00-daily-analysis.yml` already maps this variable, so setting it in Actions Secrets/Variables works out of the box | Optional | | `REPORT_SHOW_LLM_MODEL` | Whether notification report footers show the LLM model used for analysis. Defaults to `true`; set to `false` to hide runtime model metadata. This switch only affects presentation and does not change provider/model/Base URL, LiteLLM routing, or runtime model save/migration/cleanup behavior. | Optional | | `REPORT_TEMPLATES_DIR` | Jinja2 template directory (relative to project root, default `templates`) | Optional | | `REPORT_RENDERER_ENABLED` | Enable Jinja2 template rendering (default `false`, zero regression) | Optional | @@ -1333,7 +1333,7 @@ FastAPI provides RESTful API service for configuration management and triggering For this feature, the product behavior is: -- UI language is independent from report language: `dsa.uiLanguage` (browser persistence) controls shell/login/settings text, while `REPORT_LANGUAGE` controls report text and report-page fixed copy (`zh`/`en`). +- UI language is independent from report language: `dsa.uiLanguage` (browser persistence) controls shell/login/settings text, while `REPORT_LANGUAGE` controls report text and report-page fixed copy (`zh`/`en`/`ko`). - `dsa.uiLanguage` follows local persistence -> browser language -> default `zh`. - This change only adds request-scope report language override parameters; it does not modify `provider`, `model`, `base_url`, or migration/cleanup behavior. - PR-level verification output, screenshots, and command logs are maintained in PR description, not in this usage guide. @@ -1374,10 +1374,10 @@ For this feature, the product behavior is: > Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests. > Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility. > Note: `POST /api/v1/analysis/analyze` accepts `analysis_phase=auto|premarket|intraday|postmarket`, defaulting to `auto`. Non-`auto` only overrides the phase and derived phase flags for this run; it does not rewrite real trading-calendar timestamps. Accepted responses, in-memory task status, task lists, and SSE echo the requested phase, while the final report phase remains `report.meta.market_phase_summary.phase`. -> Note: `POST /api/v1/analysis/analyze` accepts `report_language=zh|en` (legacy-compatible alias `reportLanguage`). When omitted, it falls back to global `REPORT_LANGUAGE`. This parameter is request-scoped only and influences report output language for this run, including `report.meta.report_language` in responses. +> Note: `POST /api/v1/analysis/analyze` accepts `report_language=zh|en|ko` (legacy-compatible alias `reportLanguage`). When omitted, it falls back to global `REPORT_LANGUAGE`. This parameter is request-scoped only and influences report output language for this run, including `report.meta.report_language` in responses. > Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots. > Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged. -> Note: `POST /api/v1/analysis/market-review` also accepts `report_language=zh|en` / `reportLanguage` to set report language for that request. If omitted, it falls back to global `REPORT_LANGUAGE`; Bot/CLI/manual `/market-review` calls keep using global config and do not carry request-level override. +> Note: `POST /api/v1/analysis/market-review` also accepts `report_language=zh|en|ko` / `reportLanguage` to set report language for that request. If omitted, it falls back to global `REPORT_LANGUAGE`; Bot/CLI/manual `/market-review` calls keep using global config and do not carry request-level override. > Note: `POST /api/v1/analysis/market-review` is the explicit Web/desktop trigger and submits a market-review task directly. It does not short-circuit because `TRADING_DAY_CHECK_ENABLED=true` or the configured markets are closed that day; scheduled jobs, GitHub Actions manual runs, and CLI defaults still follow the trading-day gate unless `--force-run` or workflow `force_run` is used. > Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls. > Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis. diff --git a/src/agent/agents/decision_agent.py b/src/agent/agents/decision_agent.py index cf40c3e80..20d3a2e36 100644 --- a/src/agent/agents/decision_agent.py +++ b/src/agent/agents/decision_agent.py @@ -51,6 +51,8 @@ Requirements: """ if report_language == "en": return prompt + "\nAlways answer in English.\n" + if report_language == "ko": + return prompt + "\n항상 한국어로 답변하세요.\n" return prompt + "\n默认使用中文回答。\n" skills = "" @@ -137,6 +139,14 @@ should sum to 100; all-zero means no effective signal and must not be faked. - Keep every JSON key unchanged. - `decision_type` must remain `buy|hold|sell`. - Write all human-readable JSON values in English. +""" + if report_language == "ko": + return prompt + """ + +## Output Language +- Keep every JSON key unchanged. +- `decision_type` must remain `buy|hold|sell`. +- Write all human-readable JSON values in Korean (한국어). """ return prompt + """ diff --git a/src/agent/executor.py b/src/agent/executor.py index 8df0d758e..52751c821 100644 --- a/src/agent/executor.py +++ b/src/agent/executor.py @@ -818,6 +818,8 @@ class AgentExecutor: parts.append(f"报告类型: {context['report_type']}") if report_language == "en": parts.append("输出语言: English(所有 JSON 键名保持不变,所有面向用户的文本值使用英文)") + elif report_language == "ko": + parts.append("출력 언어: 한국어(모든 JSON 키는 그대로 유지하고, 사용자 노출 텍스트 값은 한국어로 작성)") else: parts.append("输出语言: 中文(所有 JSON 键名保持不变,所有面向用户的文本值使用中文)") diff --git a/src/analysis_context_pack_prompt.py b/src/analysis_context_pack_prompt.py index 71cc5f117..caeb29962 100644 --- a/src/analysis_context_pack_prompt.py +++ b/src/analysis_context_pack_prompt.py @@ -101,7 +101,9 @@ SENSITIVE_MARKERS = ( def normalize_analysis_context_pack_language(report_language: str = "zh") -> str: - return "en" if str(report_language or "").lower() == "en" else "zh" + # Korean reuses the English structural context labels; the model is + # constrained to Korean output via the analysis output-language directive. + return "en" if str(report_language or "").lower() in {"en", "ko"} else "zh" def get_analysis_context_pack_block_labels(report_language: str = "zh") -> Dict[str, str]: diff --git a/src/analyzer.py b/src/analyzer.py index d1a8c3ecf..79479964c 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -90,6 +90,8 @@ from src.report_language import ( is_chip_placeholder_value, localize_chip_health, localize_confidence_level, + localize_operation_advice, + localize_trend_prediction, normalize_report_language, ) from src.schemas.decision_action import build_action_fields @@ -101,6 +103,16 @@ from src.market_phase_prompt import format_market_phase_prompt_section logger = logging.getLogger(__name__) +def _localized_text(language: Any, *, en: str, zh: str, ko: str) -> str: + """Pick a deterministic fallback string for the report language (zh/en/ko).""" + normalized = normalize_report_language(language) + if normalized == "en": + return en + if normalized == "ko": + return ko + return zh + + def _normalize_risk_warning_values(value: Any) -> List[str]: """Normalize arbitrary risk_warning values into a flat list of text alerts.""" if value is None: @@ -232,8 +244,8 @@ def _legacy_audit_marker_specs( add("stock_code", code) add("stock_name", stock_name) add("analysis_date", context.get("date")) - add("market_phase", "## Market Phase Context" if report_language == "en" else "## 市场阶段上下文") - add("daily_market_context", "## Daily Market Context" if report_language == "en" else "## 大盘环境摘要") + add("market_phase", "## Market Phase Context" if report_language in ("en", "ko") else "## 市场阶段上下文") + add("daily_market_context", "## Daily Market Context" if report_language in ("en", "ko") else "## 大盘环境摘要") add("analysis_context_pack", analysis_context_pack_summary) add("quote", "## 📈 技术面数据") add("news_context", "## 📰 舆情情报" if news_context else None) @@ -383,25 +395,29 @@ def apply_placeholder_fill(result: "AnalysisResult", missing_fields: List[str]) report_language = normalize_report_language(getattr(result, "report_language", "zh")) placeholder = get_placeholder_text(report_language) phase_decision_placeholders = { - "dashboard.phase_decision.action_window": ( - "Model did not provide a phase action window" - if report_language == "en" - else "模型未提供阶段化行动窗口" + "dashboard.phase_decision.action_window": _localized_text( + report_language, + en="Model did not provide a phase action window", + zh="模型未提供阶段化行动窗口", + ko="모델이 단계별 행동 구간을 제공하지 않았습니다", ), - "dashboard.phase_decision.immediate_action": ( - "Model did not provide a phase-aware immediate action" - if report_language == "en" - else "模型未提供阶段化即时动作" + "dashboard.phase_decision.immediate_action": _localized_text( + report_language, + en="Model did not provide a phase-aware immediate action", + zh="模型未提供阶段化即时动作", + ko="모델이 단계 인식 즉시 동작을 제공하지 않았습니다", ), - "dashboard.phase_decision.next_check_time": ( - "Model did not provide a next check point" - if report_language == "en" - else "模型未提供下一次检查点" + "dashboard.phase_decision.next_check_time": _localized_text( + report_language, + en="Model did not provide a next check point", + zh="模型未提供下一次检查点", + ko="모델이 다음 점검 시점을 제공하지 않았습니다", ), - "dashboard.phase_decision.confidence_reason": ( - "Model did not provide a phase confidence rationale" - if report_language == "en" - else "模型未提供阶段化置信度理由" + "dashboard.phase_decision.confidence_reason": _localized_text( + report_language, + en="Model did not provide a phase confidence rationale", + zh="模型未提供阶段化置信度理由", + ko="모델이 단계별 신뢰도 근거를 제공하지 않았습니다", ), } for field in missing_fields: @@ -1461,7 +1477,7 @@ def _set_structural_hold_wording( resistance: Optional[float], flow_bias: str, ) -> None: - advice = { + advice_map = { "zh": { "range": "震荡观望", "shakeout": "洗盘观察", @@ -1472,7 +1488,14 @@ def _set_structural_hold_wording( "shakeout": "Shakeout watch", "hold": "Hold and watch", }, - }[language].get(advice_key, "持有观察" if language == "zh" else "Hold and watch") + "ko": { + "range": "박스권 관망", + "shakeout": "흔들기 관찰", + "hold": "보유 관찰", + }, + } + advice_default = {"zh": "持有观察", "en": "Hold and watch", "ko": "보유 관찰"}.get(language, "Hold and watch") + advice = advice_map.get(language, advice_map["en"]).get(advice_key, advice_default) reason_templates = { "zh": { "buy_near_resistance": "价格接近压力位且主力资金未确认流入,不宜仅因短线反弹追买。", @@ -1490,17 +1513,31 @@ def _set_structural_hold_wording( "hold_shakeout": "Price pulled back near support without confirmed outflow, which is better treated as a shakeout watch.", "hold_mid_range": "Price is between support and resistance with neutral fund flow, so range-bound watch is more actionable.", }, + "ko": { + "buy_near_resistance": "가격이 저항선에 근접했고 주력 자금 유입이 확인되지 않아 단기 반등만 보고 추격 매수하기 어렵습니다.", + "buy_with_outflow": "주력 자금 유출이 매수 결론과 상충하므로 지지 확인이나 자금 재유입을 기다려야 합니다.", + "sell_near_support": "가격이 지지선에 근접했고 지속적 유출이 없어 하루 하락만으로 매도하기 어렵습니다.", + "sell_with_inflow": "주력 자금 유입이 매도 결론과 상충하므로 우선 보유 관찰하며 지지 이탈을 추적합니다.", + "hold_shakeout": "가격이 지지선 부근까지 눌렸지만 유출이 확인되지 않아 흔들기 관찰로 처리하는 것이 적절합니다.", + "hold_mid_range": "가격이 지지선과 저항선 사이이고 자금 흐름이 불명확해 박스권 관망이 더 실행 가능합니다.", + }, } - reason = reason_templates[language].get(reason_key, "") + reason = reason_templates.get(language, reason_templates["en"]).get(reason_key, "") result.operation_advice = advice - if language == "zh" and "震荡" not in str(result.trend_prediction) and advice_key == "range": - result.trend_prediction = "震荡" - elif language == "en" and advice_key == "range": - result.trend_prediction = "Sideways" + if advice_key == "range": + if language == "zh" and "震荡" not in str(result.trend_prediction): + result.trend_prediction = "震荡" + elif language == "en": + result.trend_prediction = "Sideways" + elif language == "ko": + result.trend_prediction = "횡보" if language == "zh": no_position = "空仓先不追涨杀跌,等待支撑确认、放量突破或资金回流后再行动。" has_position = "持仓以关键支撑为风控线,未跌破前以观察和分批控仓为主。" + elif language == "ko": + no_position = "현금 보유 시 추격·투매를 삼가고 지지 확인·대량 돌파·자금 재유입 후 행동하세요." + has_position = "보유 시 핵심 지지선을 리스크 관리선으로 삼고, 이탈 전까지 관찰과 분할 관리 위주로 대응하세요." else: no_position = "Do not chase or panic; wait for support confirmation, breakout, or renewed inflow." has_position = "Use key support as the risk line and manage position size unless support fails." @@ -2268,6 +2305,17 @@ class GeminiAnalyzer: - All human-readable JSON values must be written in English. - Use the common English company name when you are confident; otherwise keep the original listed company name instead of inventing one. - This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, nested dashboard text, checklist items, and all narrative summaries. +""" + if lang == "ko": + return base_prompt + """ + +## Output Language (highest priority) + +- Keep all JSON keys unchanged. +- `decision_type` must remain `buy|hold|sell`. +- All human-readable JSON values must be written in Korean (한국어). +- Use the common Korean or original listed company name when confident; do not invent one. +- This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, nested dashboard text, checklist items, and all narrative summaries. """ return base_prompt + """ @@ -3264,6 +3312,15 @@ class GeminiAnalyzer: f"Check {field}={requested_backend} ({reason}) or set a valid " "backend/fallback before retrying." ) + elif report_language == "ko": + summary = ( + "생성 백엔드를 시작할 수 없어 AI 분석을 사용할 수 없습니다: " + f"{backend_error.error_code.value}." + ) + risk_warning = ( + f"{field}={requested_backend} ({reason})를 확인하거나 유효한 " + "백엔드/폴백을 설정한 뒤 다시 시도하세요." + ) else: summary = ( "AI 分析功能不可用:生成后端无法启动," @@ -3277,9 +3334,9 @@ class GeminiAnalyzer: code=code, name=name, sentiment_score=50, - trend_prediction='Sideways' if report_language == "en" else '震荡', - operation_advice='Hold' if report_language == "en" else '持有', - confidence_level='Low' if report_language == "en" else '低', + trend_prediction=localize_trend_prediction('震荡', report_language), + operation_advice=localize_operation_advice('持有', report_language), + confidence_level=localize_confidence_level('低', report_language), analysis_summary=summary, risk_warning=risk_warning, success=False, @@ -3296,13 +3353,28 @@ class GeminiAnalyzer: code=code, name=name, sentiment_score=50, - trend_prediction='Sideways' if report_language == "en" else '震荡', - operation_advice='Hold' if report_language == "en" else '持有', - confidence_level='Low' if report_language == "en" else '低', - analysis_summary='AI analysis is unavailable because no API key is configured.' if report_language == "en" else 'AI 分析功能未启用(未配置 API Key)', - risk_warning='Configure an LLM API key (GEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY) and retry.' if report_language == "en" else '请配置 LLM API Key(GEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY)后重试', + trend_prediction=localize_trend_prediction('震荡', report_language), + operation_advice=localize_operation_advice('持有', report_language), + confidence_level=localize_confidence_level('低', report_language), + analysis_summary=_localized_text( + report_language, + en='AI analysis is unavailable because no API key is configured.', + zh='AI 分析功能未启用(未配置 API Key)', + ko='API 키가 설정되지 않아 AI 분석을 사용할 수 없습니다.', + ), + risk_warning=_localized_text( + report_language, + en='Configure an LLM API key (GEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY) and retry.', + zh='请配置 LLM API Key(GEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY)后重试', + ko='LLM API 키(GEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY)를 설정한 뒤 다시 시도하세요.', + ), success=False, - error_message='LLM API key is not configured' if report_language == "en" else 'LLM API Key 未配置', + error_message=_localized_text( + report_language, + en='LLM API key is not configured', + zh='LLM API Key 未配置', + ko='LLM API 키가 설정되지 않았습니다', + ), model_used=None, report_language=report_language, ) @@ -3473,11 +3545,21 @@ class GeminiAnalyzer: code=code, name=name, sentiment_score=50, - trend_prediction='Sideways' if report_language == "en" else '震荡', - operation_advice='Hold' if report_language == "en" else '持有', - confidence_level='Low' if report_language == "en" else '低', - analysis_summary=(f'Analysis failed: {safe_error[:100]}' if report_language == "en" else f'分析过程出错: {safe_error[:100]}'), - risk_warning='Analysis failed. Please retry later or review manually.' if report_language == "en" else '分析失败,请稍后重试或手动分析', + trend_prediction=localize_trend_prediction('震荡', report_language), + operation_advice=localize_operation_advice('持有', report_language), + confidence_level=localize_confidence_level('低', report_language), + analysis_summary=_localized_text( + report_language, + en=f'Analysis failed: {safe_error[:100]}', + zh=f'分析过程出错: {safe_error[:100]}', + ko=f'분석 중 오류가 발생했습니다: {safe_error[:100]}', + ), + risk_warning=_localized_text( + report_language, + en='Analysis failed. Please retry later or review manually.', + zh='分析失败,请稍后重试或手动分析', + ko='분석에 실패했습니다. 잠시 후 다시 시도하거나 수동으로 검토하세요.', + ), success=False, error_message=safe_error, model_used=None, @@ -3716,7 +3798,7 @@ class GeminiAnalyzer: chip_instruction = ( "Do not fabricate profit ratio, average cost, or concentration. Mention chip data " "unavailability only once in the report; do not repeat per-field no-data text in `chip_structure`." - if report_language == "en" + if report_language in ("en", "ko") else "请勿编造获利比例、平均成本或集中度;报告中只说明一次筹码数据不可用,不要把“数据缺失,无法判断”逐字段重复写入 `chip_structure`。" ) prompt += f""" @@ -3923,6 +4005,17 @@ class GeminiAnalyzer: - This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, all nested dashboard text, checklist items, and every summary field. - Use the common English company name when you are confident. If not, keep the listed company name rather than inventing one. - When data is missing, explain it in English instead of Chinese. +""" + elif report_language == "ko": + prompt += """ + +### Output language requirements (highest priority) +- Keep every JSON key exactly as defined above; do not translate keys. +- `decision_type` must remain `buy`, `hold`, or `sell`. +- All human-readable JSON values must be in Korean (한국어). +- This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, all nested dashboard text, checklist items, and every summary field. +- Use the common Korean or original listed company name when you are confident. If not, keep the listed company name rather than inventing one. +- When data is missing, explain it in Korean instead of Chinese. """ else: prompt += f""" @@ -4036,7 +4129,7 @@ class GeminiAnalyzer: def _build_integrity_complement_prompt(self, missing_fields: List[str], report_language: str = "zh") -> str: """Build complement instruction for missing mandatory fields.""" report_language = normalize_report_language(report_language) - if report_language == "en": + if report_language in ("en", "ko"): lines = ["### Completion requirements: fill the missing mandatory fields below and output the full JSON again:"] for f in missing_fields: if f == "sentiment_score": @@ -4107,7 +4200,7 @@ class GeminiAnalyzer: """Build retry prompt using the previous response as the complement baseline.""" complement = self._build_integrity_complement_prompt(missing_fields, report_language=report_language) previous_output = previous_response.strip() - if normalize_report_language(report_language) == "en": + if normalize_report_language(report_language) in ("en", "ko"): prefix = "### The previous output is below. Complete the missing fields based on that output and return the full JSON again. Do not omit existing fields:" else: prefix = "### 上一次输出如下,请在该输出基础上补齐缺失字段,并重新输出完整 JSON。不要省略已有字段:" @@ -4290,7 +4383,7 @@ class GeminiAnalyzer: # 解析 decision_type,如果没有则根据 operation_advice 推断 decision_type = data.get('decision_type', '') if not decision_type: - op = data.get('operation_advice', 'Hold' if report_language == "en" else '持有') + op = data.get('operation_advice', localize_operation_advice('持有', report_language)) decision_type = infer_decision_type_from_advice(op, default='hold') explicit_action = data.get("action") @@ -4302,11 +4395,11 @@ class GeminiAnalyzer: name=name, # 核心指标 sentiment_score=int(data.get('sentiment_score', 50)), - trend_prediction=data.get('trend_prediction', 'Sideways' if report_language == "en" else '震荡'), - operation_advice=data.get('operation_advice', 'Hold' if report_language == "en" else '持有'), + trend_prediction=data.get('trend_prediction', localize_trend_prediction('震荡', report_language)), + operation_advice=data.get('operation_advice', localize_operation_advice('持有', report_language)), decision_type=decision_type, confidence_level=localize_confidence_level( - data.get('confidence_level', 'Medium' if report_language == "en" else '中'), + data.get('confidence_level', localize_confidence_level('中', report_language)), report_language, ), report_language=report_language, @@ -4330,13 +4423,15 @@ class GeminiAnalyzer: market_sentiment=data.get('market_sentiment', ''), hot_topics=data.get('hot_topics', ''), # 综合 - analysis_summary=data.get('analysis_summary', 'Analysis completed' if report_language == "en" else '分析完成'), + analysis_summary=data.get('analysis_summary', _localized_text( + report_language, en='Analysis completed', zh='分析完成', ko='분석 완료')), key_points=data.get('key_points', ''), risk_warning=data.get('risk_warning', ''), buy_reason=data.get('buy_reason', ''), # 元数据 search_performed=data.get('search_performed', False), - data_sources=data.get('data_sources', 'Technical data' if report_language == "en" else '技术面数据'), + data_sources=data.get('data_sources', _localized_text( + report_language, en='Technical data', zh='技术面数据', ko='기술적 데이터')), success=True, ) return populate_decision_action_fields(result, explicit_action=explicit_action) @@ -4417,8 +4512,8 @@ class GeminiAnalyzer: ) # 尝试识别关键词来判断情绪 sentiment_score = 50 - trend = 'Sideways' if report_language == "en" else '震荡' - advice = 'Hold' if report_language == "en" else '持有' + trend = localize_trend_prediction('震荡', report_language) + advice = localize_operation_advice('持有', report_language) text_lower = response_text.lower() @@ -4431,19 +4526,20 @@ class GeminiAnalyzer: if positive_count > negative_count + 1: sentiment_score = 65 - trend = 'Bullish' if report_language == "en" else '看多' - advice = 'Buy' if report_language == "en" else '买入' + trend = localize_trend_prediction('看多', report_language) + advice = localize_operation_advice('买入', report_language) decision_type = 'buy' elif negative_count > positive_count + 1: sentiment_score = 35 - trend = 'Bearish' if report_language == "en" else '看空' - advice = 'Sell' if report_language == "en" else '卖出' + trend = localize_trend_prediction('看空', report_language) + advice = localize_operation_advice('卖出', report_language) decision_type = 'sell' else: decision_type = 'hold' # 截取前500字符作为摘要 - summary = response_text[:500] if response_text else ('No analysis result' if report_language == "en" else '无分析结果') + summary = response_text[:500] if response_text else _localized_text( + report_language, en='No analysis result', zh='无分析结果', ko='분석 결과 없음') result = AnalysisResult( code=code, @@ -4452,10 +4548,20 @@ class GeminiAnalyzer: trend_prediction=trend, operation_advice=advice, decision_type=decision_type, - confidence_level='Low' if report_language == "en" else '低', + confidence_level=localize_confidence_level('低', report_language), analysis_summary=summary, - key_points='JSON parsing failed; treat this as best-effort output.' if report_language == "en" else 'JSON解析失败,仅供参考', - risk_warning='The result may be inaccurate. Cross-check with other information.' if report_language == "en" else '分析结果可能不准确,建议结合其他信息判断', + key_points=_localized_text( + report_language, + en='JSON parsing failed; treat this as best-effort output.', + zh='JSON解析失败,仅供参考', + ko='JSON 파싱에 실패했습니다. 참고용으로만 사용하세요.', + ), + risk_warning=_localized_text( + report_language, + en='The result may be inaccurate. Cross-check with other information.', + zh='分析结果可能不准确,建议结合其他信息判断', + ko='결과가 부정확할 수 있습니다. 다른 정보와 교차 확인하세요.', + ), raw_response=response_text, success=False, error_message='LLM response is not valid JSON; analysis result will not be persisted', diff --git a/src/core/config_registry.py b/src/core/config_registry.py index f892e0bf2..4a0469e9e 100644 --- a/src/core/config_registry.py +++ b/src/core/config_registry.py @@ -2503,8 +2503,9 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = { "options": [ {"label": "Chinese", "value": "zh"}, {"label": "English", "value": "en"}, + {"label": "Korean", "value": "ko"}, ], - "validation": {"enum": ["zh", "en"]}, + "validation": {"enum": ["zh", "en", "ko"]}, "display_order": 56, "help_key": "settings.notification.report_output", "examples": [ diff --git a/src/core/market_review.py b/src/core/market_review.py index 856df06d2..977f64842 100644 --- a/src/core/market_review.py +++ b/src/core/market_review.py @@ -121,6 +121,17 @@ def _get_market_review_text(language: str) -> dict[str, str]: "kr_title": "# Korea Market Recap", "separator": "> Next market recap follows", } + if normalized == "ko": + return { + "root_title": "# 🎯 시황 리뷰", + "push_title": "🎯 시황 리뷰", + "cn_title": "# 중국 A주 시황 리뷰", + "us_title": "# 미국 시황 리뷰", + "hk_title": "# 홍콩 시황 리뷰", + "jp_title": "# 일본 시황 리뷰", + "kr_title": "# 한국 시황 리뷰", + "separator": "> 다음 시장 시황 리뷰", + } return { "root_title": "# 🎯 大盘复盘", "push_title": "🎯 大盘复盘", @@ -774,6 +785,10 @@ def _persist_market_review_history( stock_name = "Market Review" operation_advice = "View review" trend_prediction = "Market review" + elif report_language == "ko": + stock_name = "시황 리뷰" + operation_advice = "리뷰 보기" + trend_prediction = "시황 리뷰" else: stock_name = "大盘复盘" operation_advice = "查看复盘" @@ -877,7 +892,11 @@ def _build_market_review_context_overview( metadata["trigger_source"] = diagnostic_snapshot.get("trigger_source") or metadata["trigger_source"] metadata["scope"] = diagnostic_snapshot.get("scope") or metadata["scope"] - label = "Market review" if report_language == "en" else "大盘复盘" + label = ( + "Market review" if report_language == "en" + else "시황 리뷰" if report_language == "ko" + else "大盘复盘" + ) return { "pack_version": "market_review/1.0", "created_at": datetime.now().isoformat(), @@ -914,4 +933,8 @@ def _summarize_market_review(review_report: str, report_language: str) -> str: text = line.strip().lstrip("#").strip() if text and not text.startswith("---") and not text.startswith(">"): return text[:200] - return "Market review report generated." if report_language == "en" else "大盘复盘报告已生成。" + if report_language == "en": + return "Market review report generated." + if report_language == "ko": + return "시황 리뷰 리포트가 생성되었습니다." + return "大盘复盘报告已生成。" diff --git a/src/core/pipeline.py b/src/core/pipeline.py index c325f9c25..1a6947f8a 100644 --- a/src/core/pipeline.py +++ b/src/core/pipeline.py @@ -38,6 +38,8 @@ from src.analyzer import ( ) from src.notification import NotificationService, NotificationChannel from src.report_language import ( + get_placeholder_text, + get_unknown_text, infer_decision_type_from_advice, localize_confidence_level, localize_operation_advice, @@ -1306,8 +1308,8 @@ class StockAnalysisPipeline: initial_context["analysis_context_pack_summary"] = analysis_context_pack_summary # 运行 Agent - if report_language == "en": - message = f"Analyze stock {code} ({stock_name}) and return the full decision dashboard JSON in English." + if report_language in ("en", "ko"): + message = f"Analyze stock {code} ({stock_name}) and return the full decision dashboard JSON." else: message = f"请分析股票 {code} ({stock_name}),并生成决策仪表盘报告。" llm_started_at = time.monotonic() @@ -1710,8 +1712,8 @@ class StockAnalysisPipeline: code=code, name=stock_name, sentiment_score=50, - trend_prediction="Unknown" if report_language == "en" else "未知", - operation_advice="Watch" if report_language == "en" else "观望", + trend_prediction=get_unknown_text(report_language), + operation_advice=localize_operation_advice("观望", report_language), confidence_level=localize_confidence_level("medium", report_language), report_language=report_language, success=agent_result.success, @@ -1791,7 +1793,7 @@ class StockAnalysisPipeline: allow_dict=True, expect_text=True, ): - result.operation_advice = str(raw_advice) if raw_advice else ("Watch" if report_language == "en" else "观望") + result.operation_advice = str(raw_advice) if raw_advice else (localize_operation_advice("观望", report_language)) else: signal_label = self._trend_signal_fallback(trend_result, report_language) if signal_label: @@ -1867,7 +1869,11 @@ class StockAnalysisPipeline: ) self._backfill_agent_dashboard_fields(result, trend_result, report_language) if not result.error_message: - result.error_message = "Agent failed to generate a valid decision dashboard" if report_language == "en" else "Agent 未能生成有效的决策仪表盘" + result.error_message = ( + "Agent failed to generate a valid decision dashboard" if report_language == "en" + else "에이전트가 유효한 결정 대시보드를 생성하지 못했습니다" if report_language == "ko" + else "Agent 未能生成有效的决策仪表盘" + ) explicit_action = dash.get("action") if isinstance(dash, dict) else None if explicit_action is None and isinstance(getattr(result, "dashboard", None), dict): @@ -2037,6 +2043,8 @@ class StockAnalysisPipeline: if trend and advice: if report_language == "en": return f"Trend view: {trend}; action advice: {advice}." + if report_language == "ko": + return f"추세 결론: {trend}; 대응 전략: {advice}." return f"趋势结论:{trend};操作建议:{advice}。" return "" @@ -2073,7 +2081,11 @@ class StockAnalysisPipeline: core["one_sentence"] = result.analysis_summary or self._summary_fallback_from_result( result, report_language, - ) or ("Analysis pending" if report_language == "en" else "分析待补充") + ) or ( + "Analysis pending" if report_language == "en" + else "분석 보완 예정" if report_language == "ko" + else "分析待补充" + ) intelligence = dashboard.get("intelligence") if not isinstance(intelligence, dict): @@ -2111,7 +2123,7 @@ class StockAnalysisPipeline: levels = getattr(trend_result, "support_levels", None) if trend_result else None if levels: return levels[0] - return "To be completed" if report_language == "en" else "待补充" + return get_placeholder_text(report_language) @staticmethod def _apply_trend_fallback( @@ -2121,7 +2133,7 @@ class StockAnalysisPipeline: ) -> None: if trend_result is None: result.sentiment_score = 50 - result.operation_advice = "Watch" if report_language == "en" else "观望" + result.operation_advice = localize_operation_advice("观望", report_language) return score = getattr(trend_result, "signal_score", None) @@ -2143,7 +2155,7 @@ class StockAnalysisPipeline: if signal_label: result.operation_advice = signal_label else: - result.operation_advice = "Watch" if report_language == "en" else "观望" + result.operation_advice = localize_operation_advice("观望", report_language) from src.agent.protocols import normalize_decision_signal diff --git a/src/daily_market_context_guardrail.py b/src/daily_market_context_guardrail.py index 45e82e321..cc2de01bb 100644 --- a/src/daily_market_context_guardrail.py +++ b/src/daily_market_context_guardrail.py @@ -6,12 +6,17 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any, List -from src.report_language import normalize_report_language +from src.report_language import ( + localize_confidence_level, + localize_operation_advice, + normalize_report_language, +) _CONSERVATIVE_TAGS = {"high_risk", "market_cooling", "conservative", "low_position_cap"} _CONSERVATIVE_TEXT_MARKERS_ZH = ("退潮", "观望", "高风险", "谨慎", "保守", "仓位上限", "仓位不超过", "轻仓") _CONSERVATIVE_TEXT_MARKERS_EN = ("high risk", "risk-off", "risk off", "watch", "cautious", "conservative", "position cap", "position limit") +_CONSERVATIVE_TEXT_MARKERS_KO = ("고위험", "관망", "위험", "신중", "보수", "비중 상한", "비중 축소", "경량") _AGGRESSIVE_BUY_MARKERS_ZH = ( "立即买入", "马上买入", @@ -25,14 +30,33 @@ _AGGRESSIVE_BUY_MARKERS_ZH = ( "加仓", ) _AGGRESSIVE_BUY_MARKERS_EN = ("buy now", "strong buy", "aggressive buy", "chase", "add aggressively") +_AGGRESSIVE_BUY_MARKERS_KO = ( + "즉시 매수", + "지금 매수", + "매수 추천", + "분할 매수", + "적극 매수", + "공격적 매수", + "추격 매수", + "비중 확대", +) _NEGATION_HINTS_ZH = ("暂不", "不建议", "不应", "不宜", "不能", "无法", "不允许", "禁止", "避免", "不要", "别", "先不") _NEGATION_HINTS_EN = (" not ", "do not", "don't", "no ", "never", "avoid") +_NEGATION_HINTS_KO = ("권하지 않", "하지 않", "하지 마", "불가", "금지", "피하", "보류", "않", "말") _NEGATION_LOOKBACK = 16 _GUARDRAIL_SENTIMENT_SCORE = 52 def _softened_operation_advice(language: str) -> str: - return "Watch" if language == "en" else "观望" + return localize_operation_advice("观望", language) + + +def _negation_hints_for(language: str) -> tuple[str, ...]: + if language == "en": + return _NEGATION_HINTS_EN + if language == "ko": + return _NEGATION_HINTS_KO + return _NEGATION_HINTS_ZH def apply_daily_market_context_guardrail( @@ -61,7 +85,7 @@ def apply_daily_market_context_guardrail( result.operation_advice = softened_advice if _is_high_confidence(getattr(result, "confidence_level", "")): - result.confidence_level = "Medium" if language == "en" else "中" + result.confidence_level = localize_confidence_level("medium", language) adjustments.append("confidence_capped_daily_market_context") result.sentiment_score = _cap_conservative_sentiment_score( @@ -116,6 +140,11 @@ def _softened_position_advice(language: str) -> dict[str, str]: "no_position": "Do not open a new position until market risk eases or confirmation appears.", "has_position": "Hold only a small position; do not increase exposure, and reduce if risk controls break.", } + if language == "ko": + return { + "no_position": "시장 위험이 완화되거나 확인 신호가 나오기 전까지 신규 진입하지 마세요.", + "has_position": "소량만 보유하고 비중을 늘리지 마세요. 리스크 관리선이 무너지면 비중을 줄이세요.", + } return { "no_position": "大盘环境偏谨慎,暂不开新仓,等待风险缓解或确认信号。", "has_position": "仅保留小仓观察,暂不扩大仓位;若跌破风控位优先降低仓位。", @@ -130,6 +159,12 @@ def _softened_position_strategy(language: str) -> dict[str, str]: "entry_plan": position_advice["no_position"], "risk_control": "Do not increase exposure before market risk eases; control drawdown strictly.", } + if language == "ko": + return { + "suggested_position": "소량/방어적 비중", + "entry_plan": position_advice["no_position"], + "risk_control": "시장 위험이 완화되기 전까지 비중을 늘리지 말고 낙폭을 엄격히 관리하세요.", + } return { "suggested_position": "小仓/低仓位", "entry_plan": position_advice["no_position"], @@ -141,23 +176,25 @@ def _append_softening_limitation(phase_decision: dict[str, Any], *, language: st limitations = phase_decision.get("data_limitations") if not isinstance(limitations, list): limitations = [] - limitation = ( - "Daily market context is conservative/high risk; aggressive buy advice was softened." - if language == "en" - else "大盘环境偏谨慎/高风险,已软化激进买入建议。" - ) + if language == "en": + limitation = "Daily market context is conservative/high risk; aggressive buy advice was softened." + elif language == "ko": + limitation = "대시장 환경이 보수적/고위험이라 공격적 매수 권고를 완화했습니다." + else: + limitation = "大盘环境偏谨慎/高风险,已软化激进买入建议。" if limitation not in limitations: limitations.append(limitation) phase_decision["data_limitations"] = limitations reason = str(phase_decision.get("confidence_reason") or "").strip() - reason_note = ( - "Market context requires conservative sizing." - if language == "en" - else "大盘环境要求降低进攻性并控制仓位。" - ) + if language == "en": + reason_note = "Market context requires conservative sizing." + elif language == "ko": + reason_note = "시장 환경상 보수적인 비중 관리가 필요합니다." + else: + reason_note = "大盘环境要求降低进攻性并控制仓位。" + separator = "; " if language == "en" else ";" phase_decision["confidence_reason"] = ( - f"{reason};{reason_note}" if reason and language != "en" else - f"{reason}; {reason_note}" if reason else reason_note + f"{reason}{separator}{reason_note}" if reason else reason_note ) @@ -171,8 +208,10 @@ def _is_conservative_context(context: Any) -> bool: return True summary = str(context.get("summary") or "") lowered = summary.lower() - return any(marker in summary for marker in _CONSERVATIVE_TEXT_MARKERS_ZH) or any( - marker in lowered for marker in _CONSERVATIVE_TEXT_MARKERS_EN + return ( + any(marker in summary for marker in _CONSERVATIVE_TEXT_MARKERS_ZH) + or any(marker in summary for marker in _CONSERVATIVE_TEXT_MARKERS_KO) + or any(marker in lowered for marker in _CONSERVATIVE_TEXT_MARKERS_EN) ) @@ -191,7 +230,11 @@ def _has_aggressive_buy_signal(result: Any, *, language: str) -> bool: def _buy_markers(language: str) -> tuple[str, ...]: - return _AGGRESSIVE_BUY_MARKERS_EN if language == "en" else _AGGRESSIVE_BUY_MARKERS_ZH + if language == "en": + return _AGGRESSIVE_BUY_MARKERS_EN + if language == "ko": + return _AGGRESSIVE_BUY_MARKERS_KO + return _AGGRESSIVE_BUY_MARKERS_ZH def _contains_any( @@ -202,7 +245,7 @@ def _contains_any( require_negation: bool = False, ) -> bool: lowered = text.lower() - negation_hints = _NEGATION_HINTS_ZH if language == "zh" else _NEGATION_HINTS_EN + negation_hints = _negation_hints_for(language) for marker in markers: marker_lower = marker.lower() marker_pos = 0 @@ -242,4 +285,4 @@ def _cap_conservative_sentiment_score(value: Any) -> int: return min(_GUARDRAIL_SENTIMENT_SCORE, max(0, score)) def _is_high_confidence(value: Any) -> bool: - return str(value or "").strip().lower() in {"高", "high"} + return str(value or "").strip().lower() in {"高", "high", "높음"} diff --git a/src/market_analyzer.py b/src/market_analyzer.py index b53ae2188..ac9be24a3 100644 --- a/src/market_analyzer.py +++ b/src/market_analyzer.py @@ -154,15 +154,20 @@ class MarketAnalyzer: def _log_context(self) -> str: return f"component=market_review region={self.region}" - def _get_review_language(self) -> str: + def _get_output_language(self) -> str: + """Return the truthful report language (zh/en/ko) for payload and directives.""" return normalize_report_language( getattr(getattr(self, "config", None), "report_language", "zh") ) + def _get_review_language(self) -> str: + # Structural/template language. Korean reuses the English scaffolding; + # the Korean output directive is applied in the prompt builder. + language = self._get_output_language() + return "en" if language == "ko" else language + def _get_template_review_language(self) -> str: - return normalize_report_language( - getattr(getattr(self, "config", None), "report_language", "zh") - ) + return self._get_review_language() def _get_market_scope_name(self, review_language: str | None = None) -> str: review_language = review_language or self._get_review_language() @@ -755,7 +760,7 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t market_light_snapshot: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Build the structured market-review contract consumed by API, Web, and notifications.""" - language = self._get_review_language() + language = self._get_output_language() sections = self._split_report_sections(report) title = self._extract_report_title(report) or self._get_review_title(overview.date).lstrip("# ").strip() light = ( @@ -1388,6 +1393,9 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t def _build_review_prompt(self, overview: MarketOverview, news: List) -> str: """构建复盘报告 Prompt""" review_language = self._get_review_language() + # Korean reuses the English structural template but the model is told to + # write the entire shell, headings, guidance and conclusion in Korean. + shell_language_label = "Korean (한국어)" if self._get_output_language() == "ko" else "English" # 指数行情信息(简洁格式,不用emoji) indices_text = "" @@ -1521,7 +1529,7 @@ Concept lagging: {bottom_concepts_text if bottom_concepts_text else "N/A"}""" - No JSON - No code blocks - Use emoji sparingly in headings (at most one per heading) -- The entire fixed shell, headings, guidance, and conclusion must be in English +- The entire fixed shell, headings, guidance, and conclusion must be in {shell_language_label} {data_boundary_requirement} --- diff --git a/src/market_context.py b/src/market_context.py index 6c3a6643e..042e3e08c 100644 --- a/src/market_context.py +++ b/src/market_context.py @@ -160,7 +160,7 @@ def get_market_role(stock_code: Optional[str], lang: str = "zh") -> str: Role string like 'A 股投资分析' or 'US stock investment analysis'. """ market = detect_market(stock_code) - lang_key = "en" if lang == "en" else "zh" + lang_key = "en" if lang in ("en", "ko") else "zh" return _MARKET_ROLES.get(market, _MARKET_ROLES["cn"])[lang_key] @@ -175,5 +175,5 @@ def get_market_guidelines(stock_code: Optional[str], lang: str = "zh") -> str: Multi-line string with market-specific guidelines. """ market = detect_market(stock_code) - lang_key = "en" if lang == "en" else "zh" + lang_key = "en" if lang in ("en", "ko") else "zh" return _MARKET_GUIDELINES.get(market, _MARKET_GUIDELINES["cn"])[lang_key] diff --git a/src/market_phase_prompt.py b/src/market_phase_prompt.py index d980c745b..ba4bd1b9a 100644 --- a/src/market_phase_prompt.py +++ b/src/market_phase_prompt.py @@ -56,7 +56,9 @@ def format_market_phase_prompt_section( if not isinstance(market_phase_context, dict) or not market_phase_context: return "" - lang = "en" if str(report_language or "").lower() == "en" else "zh" + # Korean reuses the English structural context; the output-language + # directive (see decision agent) constrains the model to write in Korean. + lang = "en" if str(report_language or "").lower() in {"en", "ko"} else "zh" raw_phase = market_phase_context.get("phase") phase = raw_phase if isinstance(raw_phase, str) and raw_phase in _KNOWN_PHASES else "unknown" diff --git a/src/market_phase_summary.py b/src/market_phase_summary.py index f377aca7a..d47ba8392 100644 --- a/src/market_phase_summary.py +++ b/src/market_phase_summary.py @@ -190,7 +190,8 @@ def format_public_phase_pack_excerpt( overview = _as_mapping(analysis_context_pack_overview) if not phase_summary and not overview: return "" - lang = "en" if str(report_language or "").lower().startswith("en") else "zh" + # Korean reuses the English structural summary; output language is set by directive. + lang = "en" if str(report_language or "").lower().startswith(("en", "ko")) else "zh" source_label = _source_label(source, lang) lines: List[str] = [] @@ -246,7 +247,8 @@ def format_public_market_status_line( if phase is None: return "" - lang = "en" if str(report_language or "").lower().startswith("en") else "zh" + # Korean reuses the English structural summary; output language is set by directive. + lang = "en" if str(report_language or "").lower().startswith(("en", "ko")) else "zh" phase_labels = _PHASE_LABELS_EN if lang == "en" else _PHASE_LABELS_ZH market_labels = _MARKET_LABELS_EN if lang == "en" else _MARKET_LABELS_ZH phase_label = phase_labels.get(phase, phase) diff --git a/src/notification.py b/src/notification.py index 9dc9a429d..768025a83 100644 --- a/src/notification.py +++ b/src/notification.py @@ -1117,12 +1117,20 @@ class NotificationService( config = get_config() report_language = self._get_report_language(results) labels = get_report_labels(report_language) - reason_label = "Rationale" if report_language == "en" else "操作理由" - risk_warning_label = "Risk Warning" if report_language == "en" else "风险提示" - technical_heading = "Technicals" if report_language == "en" else "技术面" - ma_label = "Moving Averages" if report_language == "en" else "均线" - volume_analysis_label = "Volume" if report_language == "en" else "量能" - news_heading = "News Flow" if report_language == "en" else "消息面" + + def _nlabel(en: str, zh: str, ko: str) -> str: + if report_language == "en": + return en + if report_language == "ko": + return ko + return zh + + reason_label = _nlabel("Rationale", "操作理由", "판단 근거") + risk_warning_label = _nlabel("Risk Warning", "风险提示", "리스크 경고") + technical_heading = _nlabel("Technicals", "技术面", "기술적 분석") + ma_label = _nlabel("Moving Averages", "均线", "이동평균") + volume_analysis_label = _nlabel("Volume", "量能", "거래량") + news_heading = _nlabel("News Flow", "消息面", "뉴스 흐름") if getattr(config, 'report_renderer_enabled', False) and results: from src.services.report_renderer import render out = render( diff --git a/src/phase_decision_guardrail.py b/src/phase_decision_guardrail.py index 254984e53..cb10938bd 100644 --- a/src/phase_decision_guardrail.py +++ b/src/phase_decision_guardrail.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING from src.analysis_context_pack_prompt import CORE_DEGRADED_STATUSES from src.market_phase_summary import render_market_phase_summary -from src.report_language import normalize_report_language +from src.report_language import localize_confidence_level, normalize_report_language if TYPE_CHECKING: from src.analyzer import AnalysisResult @@ -68,6 +68,50 @@ _IMMEDIATE_ACTION_MARKERS_EN = ("buy now", "sell now", "immediate buy", "immedia _NEGATION_PREFIXES_ZH = ("暂不", "不建议", "禁止", "不要", "无需", "避免", "不能", "不可", "不宜", "勿", "不") _NEGATION_PREFIXES_EN = ("do not", "don't", "dont", "not", "no", "avoid", "hold off", "without") +_KO_POSTMARKET_RECAP_PATTERNS = ( + "오늘 장 마감 후", + "장 마감 후 리뷰", + "장 마감 후", + "마감 후 리뷰", + "내일 주목", + "내일 집중", + "완전한 거래일 리뷰", +) +_IMMEDIATE_ACTION_MARKERS_KO = ("즉시 매수", "지금 매수", "즉시 비중확대", "즉시 매도", "지금 매도", "즉시 비중축소") +_NEGATION_PREFIXES_KO = ("하지", "권하지 않", "금지", "삼가", "불필요", "피하", "불가", "않", "안") + + +def _recap_patterns_for(language: str) -> tuple[str, ...]: + if language == "en": + return _EN_POSTMARKET_RECAP_PATTERNS + if language == "ko": + return _KO_POSTMARKET_RECAP_PATTERNS + return _ZH_POSTMARKET_RECAP_PATTERNS + + +def _immediate_markers_for(language: str) -> tuple[str, ...]: + if language == "en": + return _IMMEDIATE_ACTION_MARKERS_EN + if language == "ko": + return _IMMEDIATE_ACTION_MARKERS_KO + return _IMMEDIATE_ACTION_MARKERS_ZH + + +def _negations_for(language: str) -> tuple[str, ...]: + if language == "en": + return _NEGATION_PREFIXES_EN + if language == "ko": + return _NEGATION_PREFIXES_KO + return _NEGATION_PREFIXES_ZH + + +def _reason_text(language: str, *, en: str, zh: str, ko: str) -> str: + if language == "en": + return en + if language == "ko": + return ko + return zh + def apply_phase_decision_guardrails( result: "AnalysisResult", @@ -113,11 +157,12 @@ def apply_phase_decision_guardrails( initially_high_confidence = _is_high_confidence(getattr(result, "confidence_level", "")) if core_degraded and initially_high_confidence: - result.confidence_level = "Medium" if language == "en" else "中" - reason = ( - "Core quote, daily-bar, or technical data is degraded; high confidence was capped." - if language == "en" - else "核心行情、日线或技术数据受限,已限制高置信结论。" + result.confidence_level = localize_confidence_level("medium", language) + reason = _reason_text( + language, + en="Core quote, daily-bar, or technical data is degraded; high confidence was capped.", + zh="核心行情、日线或技术数据受限,已限制高置信结论。", + ko="핵심 시세·일봉·기술 데이터가 제한되어 높은 신뢰도를 하향 조정했습니다.", ) _append_reason(phase_decision, reason) adjustments.append("confidence_capped_core_data_degraded") @@ -128,22 +173,24 @@ def apply_phase_decision_guardrails( ) if has_non_intraday_action: phase_decision["immediate_action"] = _safe_wait_action(language) - reason = ( - "Current market phase does not support immediate intraday buy/sell action." - if language == "en" - else "当前市场阶段不支持即时盘中买卖动作。" + reason = _reason_text( + language, + en="Current market phase does not support immediate intraday buy/sell action.", + zh="当前市场阶段不支持即时盘中买卖动作。", + ko="현재 시장 단계에서는 즉시 장중 매수/매도 동작을 지원하지 않습니다.", ) _append_reason(phase_decision, reason) adjustments.append("non_intraday_action_adjusted") if initially_high_confidence: - result.confidence_level = "Low" if language == "en" else "低" + result.confidence_level = localize_confidence_level("low", language) adjustments.append("confidence_capped_non_intraday_action") if phase in INTRADAY_PHASES and _contains_postmarket_recap(result, phase_decision, language=language): - reason = ( - "Intraday output contained post-market recap wording; replaced with phase-safe action wording." - if language == "en" - else "盘中输出包含盘后复盘口吻,已替换为阶段安全动作表述。" + reason = _reason_text( + language, + en="Intraday output contained post-market recap wording; replaced with phase-safe action wording.", + zh="盘中输出包含盘后复盘口吻,已替换为阶段安全动作表述。", + ko="장중 출력에 장 마감 후 리뷰 표현이 있어 단계에 맞는 안전한 표현으로 교체했습니다.", ) _replace_postmarket_recap_fields(result, phase_decision, language=language) _append_reason(phase_decision, reason) @@ -212,6 +259,8 @@ def _phase_warning_limitations(summary: Optional[Mapping[str, Any]], *, language return [] if language == "en": return [f"market phase warning: {item}" for item in warnings] + if language == "ko": + return [f"시장 단계 경고: {item}" for item in warnings] return [f"市场阶段提醒:{item}" for item in warnings] @@ -228,7 +277,7 @@ def _merge_limitations(*groups: Any, limit: int = 5) -> List[str]: def _is_high_confidence(value: Any) -> bool: text = _safe_text(value).lower() - return text in {"高", "high"} + return text in {"高", "high", "높음"} def _has_immediate_buy_sell_signal( @@ -244,7 +293,7 @@ def _has_immediate_buy_sell_signal( phase_decision.get("immediate_action"), ) ).lower() - immediate_markers = _IMMEDIATE_ACTION_MARKERS_EN if language == "en" else _IMMEDIATE_ACTION_MARKERS_ZH + immediate_markers = _immediate_markers_for(language) if _contains_non_negated_marker(haystack, immediate_markers, language=language): return True return _safe_text(getattr(result, "decision_type", "")).lower() in {"buy", "sell"} @@ -270,7 +319,7 @@ def _contains_non_negated_marker(text: str, markers: tuple[str, ...], *, languag def _is_negated_marker(text: str, marker_index: int, *, language: str) -> bool: window = 24 if language == "en" else 8 prefix = text[max(0, marker_index - window):marker_index].rstrip() - negations = _NEGATION_PREFIXES_EN if language == "en" else _NEGATION_PREFIXES_ZH + negations = _negations_for(language) return any(prefix.endswith(item) for item in negations) @@ -285,7 +334,7 @@ def _contains_postmarket_recap(result: "AnalysisResult", phase_decision: Mapping getattr(result, "analysis_summary", ""), phase_decision.get("immediate_action"), ) - patterns = _EN_POSTMARKET_RECAP_PATTERNS if language == "en" else _ZH_POSTMARKET_RECAP_PATTERNS + patterns = _recap_patterns_for(language) return any(_contains_any(value, patterns) for value in values) @@ -302,11 +351,14 @@ def _replace_postmarket_recap_fields( core = {} dashboard["core_conclusion"] = core safe_action = _safe_wait_action(language) - safe_summary = ( - "This is an intraday phase; use live state, watch conditions, and the next " - "check point rather than post-market recap wording." - if language == "en" - else "当前处于盘中阶段,应以实时状态、观察条件和下一次检查点为准,避免盘后复盘口径。" + safe_summary = _reason_text( + language, + en=( + "This is an intraday phase; use live state, watch conditions, and the next " + "check point rather than post-market recap wording." + ), + zh="当前处于盘中阶段,应以实时状态、观察条件和下一次检查点为准,避免盘后复盘口径。", + ko="현재 장중 단계이므로 장 마감 후 리뷰 표현 대신 실시간 상태·관찰 조건·다음 점검 시점을 기준으로 합니다.", ) if _contains_any(core.get("one_sentence"), _patterns(language)): core["one_sentence"] = safe_action @@ -329,26 +381,47 @@ def _append_reason(phase_decision: Dict[str, Any], reason: str) -> None: def _adjustment_limitation_text(adjustment: str, *, language: str) -> str: if adjustment == "postmarket_recap_wording_adjusted": - return "post-market recap wording adjusted" if language == "en" else "已修正盘后复盘口吻" + return _reason_text( + language, + en="post-market recap wording adjusted", + zh="已修正盘后复盘口吻", + ko="장 마감 후 리뷰 표현을 수정함", + ) if adjustment == "non_intraday_action_adjusted": - return "non-intraday immediate action adjusted" if language == "en" else "非盘中阶段已修正即时买卖动作" + return _reason_text( + language, + en="non-intraday immediate action adjusted", + zh="非盘中阶段已修正即时买卖动作", + ko="비장중 단계의 즉시 매매 동작을 수정함", + ) if adjustment == "confidence_capped_non_intraday_action": - return "confidence capped for non-intraday action" if language == "en" else "非盘中阶段已限制买卖置信度" + return _reason_text( + language, + en="confidence capped for non-intraday action", + zh="非盘中阶段已限制买卖置信度", + ko="비장중 단계 매매에 대해 신뢰도를 제한함", + ) if adjustment == "confidence_capped_core_data_degraded": - return "confidence capped due to degraded core data" if language == "en" else "核心数据受限已降低置信度" + return _reason_text( + language, + en="confidence capped due to degraded core data", + zh="核心数据受限已降低置信度", + ko="핵심 데이터 제한으로 신뢰도를 낮춤", + ) return adjustment def _safe_wait_action(language: str) -> str: - return ( - "Wait for intraday confirmation; do not chase." - if language == "en" - else "等待盘中确认,禁止追高。" + return _reason_text( + language, + en="Wait for intraday confirmation; do not chase.", + zh="等待盘中确认,禁止追高。", + ko="장중 확인을 기다리고 추격 매수하지 마세요.", ) def _patterns(language: str) -> tuple[str, ...]: - return _EN_POSTMARKET_RECAP_PATTERNS if language == "en" else _ZH_POSTMARKET_RECAP_PATTERNS + return _recap_patterns_for(language) def _contains_any(value: Any, patterns: tuple[str, ...]) -> bool: diff --git a/src/report_language.py b/src/report_language.py index 8ec41b0a1..577c738c3 100644 --- a/src/report_language.py +++ b/src/report_language.py @@ -6,7 +6,7 @@ from __future__ import annotations import re from typing import Any, Dict, Optional -SUPPORTED_REPORT_LANGUAGES = ("zh", "en") +SUPPORTED_REPORT_LANGUAGES = ("zh", "en", "ko") _REPORT_LANGUAGE_ALIASES = { "zh-cn": "zh", @@ -22,6 +22,10 @@ _REPORT_LANGUAGE_ALIASES = { "en_us": "en", "en-gb": "en", "en_gb": "en", + "korean": "ko", + "kr": "ko", + "ko-kr": "ko", + "ko_kr": "ko", } _OPERATION_ADVICE_CANONICAL_MAP = { @@ -49,16 +53,24 @@ _OPERATION_ADVICE_CANONICAL_MAP = { "强烈卖出": "strong_sell", "strong sell": "strong_sell", "strong_sell": "strong_sell", + "적극 매수": "strong_buy", + "매수": "buy", + "보유": "hold", + "보유 관찰": "hold", + "관망": "watch", + "비중축소": "reduce", + "매도": "sell", + "적극 매도": "strong_sell", } _OPERATION_ADVICE_TRANSLATIONS = { - "strong_buy": {"zh": "强烈买入", "en": "Strong Buy"}, - "buy": {"zh": "买入", "en": "Buy"}, - "hold": {"zh": "持有", "en": "Hold"}, - "watch": {"zh": "观望", "en": "Watch"}, - "reduce": {"zh": "减仓", "en": "Reduce"}, - "sell": {"zh": "卖出", "en": "Sell"}, - "strong_sell": {"zh": "强烈卖出", "en": "Strong Sell"}, + "strong_buy": {"zh": "强烈买入", "en": "Strong Buy", "ko": "적극 매수"}, + "buy": {"zh": "买入", "en": "Buy", "ko": "매수"}, + "hold": {"zh": "持有", "en": "Hold", "ko": "보유"}, + "watch": {"zh": "观望", "en": "Watch", "ko": "관망"}, + "reduce": {"zh": "减仓", "en": "Reduce", "ko": "비중축소"}, + "sell": {"zh": "卖出", "en": "Sell", "ko": "매도"}, + "strong_sell": {"zh": "强烈卖出", "en": "Strong Sell", "ko": "적극 매도"}, } _TREND_PREDICTION_CANONICAL_MAP = { @@ -85,14 +97,19 @@ _TREND_PREDICTION_CANONICAL_MAP = { "强烈看空": "strong_bearish", "strong bearish": "strong_bearish", "very bearish": "strong_bearish", + "강한 상승": "strong_bullish", + "상승": "bullish", + "횡보": "sideways", + "하락": "bearish", + "강한 하락": "strong_bearish", } _TREND_PREDICTION_TRANSLATIONS = { - "strong_bullish": {"zh": "强烈看多", "en": "Strong Bullish"}, - "bullish": {"zh": "看多", "en": "Bullish"}, - "sideways": {"zh": "震荡", "en": "Sideways"}, - "bearish": {"zh": "看空", "en": "Bearish"}, - "strong_bearish": {"zh": "强烈看空", "en": "Strong Bearish"}, + "strong_bullish": {"zh": "强烈看多", "en": "Strong Bullish", "ko": "강한 상승"}, + "bullish": {"zh": "看多", "en": "Bullish", "ko": "상승"}, + "sideways": {"zh": "震荡", "en": "Sideways", "ko": "횡보"}, + "bearish": {"zh": "看空", "en": "Bearish", "ko": "하락"}, + "strong_bearish": {"zh": "强烈看空", "en": "Strong Bearish", "ko": "강한 하락"}, } _CONFIDENCE_LEVEL_CANONICAL_MAP = { @@ -103,12 +120,15 @@ _CONFIDENCE_LEVEL_CANONICAL_MAP = { "med": "medium", "低": "low", "low": "low", + "높음": "high", + "보통": "medium", + "낮음": "low", } _CONFIDENCE_LEVEL_TRANSLATIONS = { - "high": {"zh": "高", "en": "High"}, - "medium": {"zh": "中", "en": "Medium"}, - "low": {"zh": "低", "en": "Low"}, + "high": {"zh": "高", "en": "High", "ko": "높음"}, + "medium": {"zh": "中", "en": "Medium", "ko": "보통"}, + "low": {"zh": "低", "en": "Low", "ko": "낮음"}, } _CHIP_HEALTH_CANONICAL_MAP = { @@ -118,12 +138,15 @@ _CHIP_HEALTH_CANONICAL_MAP = { "average": "average", "警惕": "caution", "caution": "caution", + "양호": "healthy", + "보통": "average", + "주의": "caution", } _CHIP_HEALTH_TRANSLATIONS = { - "healthy": {"zh": "健康", "en": "Healthy"}, - "average": {"zh": "一般", "en": "Average"}, - "caution": {"zh": "警惕", "en": "Caution"}, + "healthy": {"zh": "健康", "en": "Healthy", "ko": "양호"}, + "average": {"zh": "一般", "en": "Average", "ko": "보통"}, + "caution": {"zh": "警惕", "en": "Caution", "ko": "주의"}, } _BIAS_STATUS_CANONICAL_MAP = { @@ -135,32 +158,39 @@ _BIAS_STATUS_CANONICAL_MAP = { "危险": "danger", "risk": "danger", "danger": "danger", + "안전": "safe", + "경계": "caution", + "위험": "danger", } _BIAS_STATUS_TRANSLATIONS = { - "safe": {"zh": "安全", "en": "Safe"}, - "caution": {"zh": "警戒", "en": "Caution"}, - "danger": {"zh": "危险", "en": "Danger"}, + "safe": {"zh": "安全", "en": "Safe", "ko": "안전"}, + "caution": {"zh": "警戒", "en": "Caution", "ko": "경계"}, + "danger": {"zh": "危险", "en": "Danger", "ko": "위험"}, } _PLACEHOLDER_BY_LANGUAGE = { "zh": "待补充", "en": "TBD", + "ko": "미정", } _UNKNOWN_BY_LANGUAGE = { "zh": "未知", "en": "Unknown", + "ko": "알 수 없음", } _NO_DATA_BY_LANGUAGE = { "zh": "数据缺失", "en": "Data unavailable", + "ko": "데이터 없음", } _CHIP_UNAVAILABLE_BY_LANGUAGE = { "zh": "筹码分布未启用或数据源暂不可用,未纳入筹码判断。", "en": "Chip distribution is disabled or temporarily unavailable; chip signals were not used.", + "ko": "매물대가 비활성화되었거나 데이터 소스를 일시적으로 사용할 수 없어 매물대 신호를 반영하지 않았습니다.", } _CHIP_PLACEHOLDER_EXACT = { @@ -197,6 +227,7 @@ _CHIP_UNAVAILABLE_REASON_KEYS = ( _GENERIC_STOCK_NAME_BY_LANGUAGE = { "zh": "待确认股票", "en": "Unnamed Stock", + "ko": "미확인 종목", } _REPORT_LABELS: Dict[str, Dict[str, str]] = { @@ -436,6 +467,124 @@ _REPORT_LABELS: Dict[str, Dict[str, str]] = { "strongest_bullish_signal_label": "Strongest Bullish Signal", "strongest_bearish_signal_label": "Strongest Bearish Signal", }, + "ko": { + "dashboard_title": "결정 대시보드", + "brief_title": "결정 브리핑", + "analyzed_prefix": "분석 종목", + "stock_unit": "개 종목", + "stock_unit_compact": "개", + "buy_label": "매수", + "watch_label": "관망", + "sell_label": "매도", + "summary_heading": "분석 결과 요약", + "info_heading": "핵심 업데이트", + "sentiment_summary_label": "투자심리", + "earnings_outlook_label": "실적 전망", + "risk_alerts_label": "리스크 경보", + "positive_catalysts_label": "긍정 촉매", + "latest_news_label": "최신 뉴스", + "core_conclusion_heading": "핵심 결론", + "one_sentence_label": "한 줄 결론", + "time_sensitivity_label": "시의성", + "default_time_sensitivity": "이번 주", + "position_status_label": "보유 상태", + "action_advice_label": "대응 전략", + "no_position_label": "미보유", + "has_position_label": "보유 중", + "continue_holding": "보유 유지", + "market_snapshot_heading": "시세 스냅샷", + "close_label": "종가", + "prev_close_label": "전일 종가", + "open_label": "시가", + "high_label": "고가", + "low_label": "저가", + "change_pct_label": "등락률", + "change_amount_label": "등락액", + "amplitude_label": "변동폭", + "volume_label": "거래량", + "amount_label": "거래대금", + "current_price_label": "현재가", + "volume_ratio_label": "거래량비", + "turnover_rate_label": "회전율", + "source_label": "시세 출처", + "data_perspective_heading": "데이터 분석", + "ma_alignment_label": "이동평균 배열", + "bullish_alignment_label": "정배열", + "yes_label": "예", + "no_label": "아니오", + "trend_strength_label": "추세 강도", + "price_metrics_label": "가격 지표", + "ma5_label": "MA5", + "ma10_label": "MA10", + "ma20_label": "MA20", + "bias_ma5_label": "이격도(MA5)", + "support_level_label": "지지선", + "resistance_level_label": "저항선", + "chip_label": "매물대", + "phase_decision_heading": "장중 결정 가드레일", + "action_window_label": "대응 시점", + "immediate_action_label": "현재 행동", + "watch_conditions_label": "관찰 조건", + "next_check_time_label": "다음 점검", + "confidence_reason_label": "신뢰도 근거", + "data_limitations_label": "데이터 한계", + "battle_plan_heading": "실행 계획", + "ideal_buy_label": "이상적 매수가", + "secondary_buy_label": "추가 매수가", + "stop_loss_label": "손절가", + "take_profit_label": "목표가", + "suggested_position_label": "비중 제안", + "entry_plan_label": "진입 전략", + "risk_control_label": "리스크 관리", + "checklist_heading": "체크리스트", + "failed_checks_heading": "미충족 항목", + "history_compare_heading": "과거 신호 비교", + "time_label": "시간", + "score_label": "점수", + "advice_label": "제안", + "trend_label": "추세", + "generated_at_label": "생성 시각", + "report_time_label": "생성", + "no_results": "분석 결과 없음", + "report_title": "종목 분석 리포트", + "avg_score_label": "평균 점수", + "action_points_heading": "대응 가격대", + "position_advice_heading": "보유 전략", + "analysis_model_label": "분석 모델", + "not_investment_advice": "AI 생성 참고용이며 투자 권유가 아닙니다.", + "details_report_hint": "상세 리포트 보기:", + "financial_summary_heading": "재무 요약", + "report_date_label": "보고 기준", + "revenue_label": "매출액", + "net_profit_label": "지배주주 순이익", + "operating_cash_flow_label": "영업 현금흐름", + "roe_label": "ROE", + "revenue_yoy_label": "매출 전년比", + "net_profit_yoy_label": "순이익 전년比", + "gross_margin_label": "매출총이익률", + "shareholder_return_heading": "주주 환원", + "ttm_cash_dividend_label": "최근 12개월 주당 현금배당(세전)", + "ttm_event_count_label": "최근 12개월 배당 횟수", + "ttm_dividend_yield_label": "TTM 배당수익률", + "latest_ex_dividend_label": "최근 배당락일", + "related_boards_heading": "관련 섹터", + "industry_boards_heading": "업종 섹터", + "concept_boards_heading": "테마 섹터", + "board_name_label": "섹터", + "board_type_label": "유형", + "board_status_label": "섹터 상태", + "board_change_pct_label": "섹터 등락률", + "leading_board_label": "강세", + "lagging_board_label": "약세", + "signal_attribution_heading": "신호 귀인 분석", + "attribution_weights_label": "귀인 가중치", + "technical_indicators_label": "기술 지표", + "news_sentiment_label": "뉴스 심리", + "fundamentals_label": "펀더멘털", + "market_conditions_label": "시장 환경", + "strongest_bullish_signal_label": "최강 상승 신호", + "strongest_bearish_signal_label": "최강 하락 신호", + }, } _DECISION_INTENT_NEGATIONS = ( @@ -837,6 +986,17 @@ def get_sentiment_label(score: int, language: Optional[str]) -> str: return "Bearish" return "Very Bearish" + if normalized == "ko": + if score >= 80: + return "매우 낙관" + if score >= 60: + return "낙관" + if score >= 40: + return "중립" + if score >= 20: + return "비관" + return "매우 비관" + if score >= 80: return "极度乐观" if score >= 60: diff --git a/src/services/daily_market_context.py b/src/services/daily_market_context.py index 71b6495d8..e2002741e 100644 --- a/src/services/daily_market_context.py +++ b/src/services/daily_market_context.py @@ -659,7 +659,7 @@ def format_daily_market_context_prompt_section( position_cap = str(payload.get("position_cap") or "").strip() source = str(payload.get("source") or "").strip() - if language == "en": + if language in ("en", "ko"): label = _REGION_LABEL_EN.get(region, region) lines = [ "\n## Daily Market Context", diff --git a/src/services/history_service.py b/src/services/history_service.py index 484655e40..f35a22150 100644 --- a/src/services/history_service.py +++ b/src/services/history_service.py @@ -896,14 +896,22 @@ class HistoryService: report_time = record.created_at.strftime("%H:%M:%S") if record.created_at else datetime.now().strftime("%H:%M:%S") report_language = normalize_report_language(getattr(result, "report_language", "zh")) labels = get_report_labels(report_language) - analysis_date_label = "Analysis Date" if report_language == "en" else "分析日期" - report_time_label = "Report Time" if report_language == "en" else "报告生成时间" - reason_label = "Rationale" if report_language == "en" else "操作理由" - risk_warning_label = "Risk Warning" if report_language == "en" else "风险提示" - technical_heading = "Technicals" if report_language == "en" else "技术面" - ma_label = "Moving Averages" if report_language == "en" else "均线" - volume_analysis_label = "Volume" if report_language == "en" else "量能" - news_heading = "News Flow" if report_language == "en" else "消息面" + + def _label(en: str, zh: str, ko: str) -> str: + if report_language == "en": + return en + if report_language == "ko": + return ko + return zh + + analysis_date_label = _label("Analysis Date", "分析日期", "분석일") + report_time_label = _label("Report Time", "报告生成时间", "생성 시각") + reason_label = _label("Rationale", "操作理由", "판단 근거") + risk_warning_label = _label("Risk Warning", "风险提示", "리스크 경고") + technical_heading = _label("Technicals", "技术面", "기술적 분석") + ma_label = _label("Moving Averages", "均线", "이동평균") + volume_analysis_label = _label("Volume", "量能", "거래량") + news_heading = _label("News Flow", "消息面", "뉴스 흐름") # Escape markdown special characters in stock name name_escaped = self._escape_md( diff --git a/tests/test_api_schema_pydantic.py b/tests/test_api_schema_pydantic.py index 5a1721159..83e8bd76c 100644 --- a/tests/test_api_schema_pydantic.py +++ b/tests/test_api_schema_pydantic.py @@ -109,6 +109,20 @@ def test_request_models_accept_report_language_camel_case_alias() -> None: assert market_review_request.report_language == "en" +def test_request_models_accept_korean_report_language() -> None: + analyze_request = AnalyzeRequest.model_validate({ + "stock_code": "005930.KS", + "report_language": "ko", + }) + assert analyze_request.report_language == "ko" + + market_review_request = MarketReviewRequest.model_validate({ + "send_notification": False, + "report_language": "ko", + }) + assert market_review_request.report_language == "ko" + + def test_analyze_request_analysis_phase_defaults_to_auto() -> None: request = AnalyzeRequest(stock_code="600519") diff --git a/tests/test_report_language.py b/tests/test_report_language.py index 930cc5c45..efd590ccc 100644 --- a/tests/test_report_language.py +++ b/tests/test_report_language.py @@ -4,13 +4,17 @@ import unittest from src.report_language import ( + SUPPORTED_REPORT_LANGUAGES, get_bias_status_emoji, get_localized_stock_name, + get_report_labels, get_sentiment_label, get_signal_level, infer_decision_type_from_advice, + localize_operation_advice, localize_trend_prediction, localize_bias_status, + normalize_report_language, ) @@ -74,5 +78,61 @@ class ReportLanguageTestCase(unittest.TestCase): ) +class KoreanReportLanguageTestCase(unittest.TestCase): + def test_korean_is_supported(self) -> None: + self.assertIn("ko", SUPPORTED_REPORT_LANGUAGES) + + def test_normalize_korean_aliases(self) -> None: + self.assertEqual(normalize_report_language("ko"), "ko") + self.assertEqual(normalize_report_language("korean"), "ko") + self.assertEqual(normalize_report_language("ko-KR"), "ko") + self.assertEqual(normalize_report_language("kr"), "ko") + + def test_unknown_language_falls_back_to_default(self) -> None: + self.assertEqual(normalize_report_language("fr"), "zh") + self.assertEqual(normalize_report_language(None), "zh") + + def test_korean_labels_cover_full_english_key_set(self) -> None: + ko_labels = get_report_labels("ko") + en_labels = get_report_labels("en") + self.assertEqual(set(ko_labels.keys()), set(en_labels.keys())) + self.assertEqual(ko_labels["dashboard_title"], "결정 대시보드") + self.assertEqual(ko_labels["risk_alerts_label"], "리스크 경보") + + def test_korean_sentiment_label_bands(self) -> None: + self.assertEqual(get_sentiment_label(80, "ko"), "매우 낙관") + self.assertEqual(get_sentiment_label(40, "ko"), "중립") + self.assertEqual(get_sentiment_label(0, "ko"), "매우 비관") + + def test_korean_operation_advice_and_trend(self) -> None: + self.assertEqual(localize_operation_advice("买入", "ko"), "매수") + self.assertEqual(localize_operation_advice("strong sell", "ko"), "적극 매도") + self.assertEqual(localize_trend_prediction("bullish", "ko"), "상승") + + def test_korean_localized_stock_name_placeholder(self) -> None: + self.assertEqual( + get_localized_stock_name("股票AAPL", "AAPL", "ko"), + "미확인 종목", + ) + + def test_existing_languages_unchanged(self) -> None: + self.assertEqual(get_sentiment_label(80, "en"), "Very Bullish") + self.assertEqual(get_sentiment_label(40, "zh"), "中性") + + def test_korean_advice_canonicalizes_to_decision_type(self) -> None: + self.assertEqual(infer_decision_type_from_advice("매수"), "buy") + self.assertEqual(infer_decision_type_from_advice("매도"), "sell") + self.assertEqual(infer_decision_type_from_advice("보유"), "hold") + self.assertEqual(infer_decision_type_from_advice("관망"), "hold") + + def test_korean_advice_resolves_signal_level(self) -> None: + self.assertEqual(get_signal_level("매수", 72, "ko"), ("매수", "🟢", "buy")) + self.assertEqual(get_signal_level("매도", 30, "ko"), ("매도", "🔴", "sell")) + + def test_korean_values_canonicalize_back_for_other_languages(self) -> None: + self.assertEqual(localize_trend_prediction("상승", "en"), "Bullish") + self.assertEqual(localize_operation_advice("적극 매도", "zh"), "强烈卖出") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_report_language_fallbacks_ko.py b/tests/test_report_language_fallbacks_ko.py new file mode 100644 index 000000000..05e7271d2 --- /dev/null +++ b/tests/test_report_language_fallbacks_ko.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +"""Korean (ko) deterministic fallback coverage for the report-language sweep (#1614).""" + +import unittest + +from src.core.market_review import _get_market_review_text +from src.report_language import ( + get_no_data_text, + get_placeholder_text, + get_unknown_text, +) + + +class KoreanFallbackTextTestCase(unittest.TestCase): + def test_placeholder_unknown_no_data_have_korean(self) -> None: + self.assertEqual(get_placeholder_text("ko"), "미정") + self.assertEqual(get_unknown_text("ko"), "알 수 없음") + self.assertEqual(get_no_data_text("ko"), "데이터 없음") + + def test_market_review_titles_korean(self) -> None: + text = _get_market_review_text("ko") + self.assertEqual(text["push_title"], "🎯 시황 리뷰") + self.assertIn("시황 리뷰", text["root_title"]) + self.assertIn("한국", text["kr_title"]) + + def test_market_review_titles_unchanged_for_en_zh(self) -> None: + self.assertEqual(_get_market_review_text("en")["push_title"], "🎯 Market Review") + self.assertEqual(_get_market_review_text("zh")["push_title"], "🎯 大盘复盘") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report_language_prompt_directives.py b/tests/test_report_language_prompt_directives.py new file mode 100644 index 000000000..95b1af63d --- /dev/null +++ b/tests/test_report_language_prompt_directives.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""Tests for Korean (ko) output-language directives in analysis prompts (#1614).""" + +import unittest + +from src.agent.agents.decision_agent import DecisionAgent +from src.agent.protocols import AgentContext +from src.analysis_context_pack_prompt import normalize_analysis_context_pack_language +from src.market_phase_prompt import format_market_phase_prompt_section + + +def _phase_ctx(): + return { + "market": "us", + "phase": "premarket", + "market_local_time": "2026-06-29T08:00:00-04:00", + "effective_daily_bar_date": "2026-06-27", + "minutes_to_open": 90, + "warnings": [], + } + + +class DecisionAgentLanguageDirectiveTestCase(unittest.TestCase): + def setUp(self) -> None: + self.agent = DecisionAgent(tool_registry=None, llm_adapter=None) + + def _system_prompt(self, language: str, *, chat: bool = False) -> str: + meta = {"report_language": language} + if chat: + meta["response_mode"] = "chat" + ctx = AgentContext(stock_code="005930.KS", stock_name="삼성전자", meta=meta) + return self.agent.system_prompt(ctx) + + def test_korean_dashboard_directive(self) -> None: + prompt = self._system_prompt("ko") + self.assertIn("Write all human-readable JSON values in Korean (한국어).", prompt) + self.assertIn("`decision_type` must remain `buy|hold|sell`.", prompt) + + def test_korean_chat_directive(self) -> None: + prompt = self._system_prompt("ko", chat=True) + self.assertIn("항상 한국어로 답변하세요.", prompt) + + def test_english_directive_unchanged(self) -> None: + prompt = self._system_prompt("en") + self.assertIn("Write all human-readable JSON values in English.", prompt) + + def test_chinese_directive_unchanged(self) -> None: + prompt = self._system_prompt("zh") + self.assertIn("所有面向用户的人类可读文本值必须使用中文。", prompt) + + +class StructuralLanguageRoutingTestCase(unittest.TestCase): + def test_context_pack_korean_reuses_english_scaffolding(self) -> None: + self.assertEqual(normalize_analysis_context_pack_language("ko"), "en") + self.assertEqual(normalize_analysis_context_pack_language("en"), "en") + self.assertEqual(normalize_analysis_context_pack_language("zh"), "zh") + + def test_market_phase_korean_matches_english_structure(self) -> None: + ko_section = format_market_phase_prompt_section(_phase_ctx(), report_language="ko") + en_section = format_market_phase_prompt_section(_phase_ctx(), report_language="en") + self.assertEqual(ko_section, en_section) + self.assertIn("## Market Phase Context", ko_section) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_system_config_service.py b/tests/test_system_config_service.py index 97426a696..d932694da 100644 --- a/tests/test_system_config_service.py +++ b/tests/test_system_config_service.py @@ -1908,8 +1908,9 @@ class SystemConfigServiceTestCase(unittest.TestCase): self.assertEqual(agent_arch_schema["validation"]["enum"], ["single", "multi"]) report_language_schema = items["REPORT_LANGUAGE"]["schema"] - self.assertEqual(report_language_schema["validation"]["enum"], ["zh", "en"]) + self.assertEqual(report_language_schema["validation"]["enum"], ["zh", "en", "ko"]) self.assertEqual(report_language_schema["options"][1]["value"], "en") + self.assertEqual(report_language_schema["options"][2]["value"], "ko") self.assertEqual(items["AGENT_ORCHESTRATOR_TIMEOUT_S"]["schema"]["default_value"], "600") self.assertTrue(items["AGENT_DEEP_RESEARCH_BUDGET"]["schema"]["is_editable"]) @@ -1968,6 +1969,12 @@ class SystemConfigServiceTestCase(unittest.TestCase): self.assertTrue(validation["valid"]) self.assertEqual(validation["issues"], []) + def test_validate_accepts_report_language_korean(self) -> None: + validation = self.service.validate(items=[{"key": "REPORT_LANGUAGE", "value": "ko"}]) + + self.assertTrue(validation["valid"]) + self.assertEqual(validation["issues"], []) + def test_validate_accepts_comma_separated_market_review_region(self) -> None: validation = self.service.validate( items=[{"key": "MARKET_REVIEW_REGION", "value": "cn,jp,us"}]