feat: 报告输出语言新增韩语支持 (Korean report language, ko) (#1614) (#1844)

* 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
This commit is contained in:
HyunRyeol Park
2026-06-30 20:02:46 +09:00
committed by GitHub
parent 2a65384654
commit 043c60378e
37 changed files with 1049 additions and 193 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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):

View File

@@ -52,6 +52,14 @@ const BLOCK_LABELS: Record<ReportLanguage, Record<string, string>> = {
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<ReportLanguage, Record<string, string>> = {
@@ -138,6 +176,16 @@ const MISSING_REASON_LABELS: Record<ReportLanguage, Record<string, string>> = {
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) {

View File

@@ -276,6 +276,29 @@ const MARKET_REVIEW_TEXT: Record<ReportLanguage, {
leading: 'Leading',
lagging: 'Lagging',
},
ko: {
reviewSummary: '리뷰 요약',
noReviewSummary: '요약 없음',
noSentimentScore: '점수 없음',
rotationAndFunds: '순환과 자금',
noRotationView: '순환 관점 없음',
riskAndWatch: '리스크와 관찰',
noRiskWatch: '관찰 포인트 없음',
structuredMarketData: '구조화 시장 데이터',
noBreadthData: '데이터 없음',
advancers: '상승 종목 수',
decliners: '하락 종목 수',
limitUpDown: '상한가/하한가',
turnover: '거래대금',
index: '지수',
last: '현재',
change: '등락률',
highLow: '고가/저가',
industryBoards: '업종 섹터',
conceptBoards: '테마 섹터',
leading: '강세',
lagging: '약세',
},
};
const formatRankingChange = (value: unknown): string => {
@@ -298,7 +321,7 @@ export const MarketReviewReportView: React.FC<MarketReviewReportViewProps> = ({
}) => {
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<LoadedMarkdown | null>(null);
const [loadError, setLoadError] = useState<LoadError | null>(null);

View File

@@ -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<RunDiagnosticStatus, { variant: BadgeVariant; tone: StatusTone }> = {
@@ -140,7 +170,7 @@ export const ReportDiagnostics: React.FC<ReportDiagnosticsProps> = ({
}) => {
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;

View File

@@ -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;
/**

View File

@@ -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 '中性';

View File

@@ -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');
});
});

View File

@@ -19,6 +19,12 @@ const REQUEST_PHASE_LABELS: Record<ReportLanguage, Record<AnalysisPhase, string>
intraday: 'Intraday',
postmarket: 'Post-market',
},
ko: {
auto: '자동 단계',
premarket: '장 시작 전',
intraday: '장중',
postmarket: '장 마감 후',
},
};
const MARKET_PHASE_LABELS: Record<ReportLanguage, Record<MarketPhaseValue, string>> = {
@@ -40,6 +46,15 @@ const MARKET_PHASE_LABELS: Record<ReportLanguage, Record<MarketPhaseValue, strin
non_trading: 'Non-trading',
unknown: 'Unknown phase',
},
ko: {
premarket: '장 시작 전',
intraday: '장중',
lunch_break: '점심 휴장',
closing_auction: '마감 임박',
postmarket: '장 마감 후',
non_trading: '비거래일',
unknown: '단계 불명',
},
};
const TEXT = {
@@ -53,6 +68,11 @@ const TEXT = {
finalPrefix: 'Market phase',
partialBar: 'Partial bar',
},
ko: {
requestPrefix: '요청 단계',
finalPrefix: '시장 단계',
partialBar: '일봉 미완성',
},
} as const;
export const getRequestedPhaseLabel = (

View File

@@ -1,7 +1,7 @@
import type { ReportLanguage } from '../types/analysis';
export const normalizeReportLanguage = (value?: string | null): ReportLanguage =>
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)];

View File

@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [新功能] 报告输出语言新增韩语(`REPORT_LANGUAGE=ko`),覆盖个股报告、大盘复盘、提示词输出语言、决策护栏、通知模板标签与 Web 报告详情页文案;`ko` 复用英文结构骨架并约束模型用韩文输出,`zh`/`en` 行为保持不变 (#1614)
- [修复] 修复 Web 首页个股栏在 stock-bar 摘要字段缺失或动作建议无法归类时隐藏情绪分与建议标识的问题。

View File

@@ -4116,7 +4116,8 @@
"type": "string",
"enum": [
"zh",
"en"
"en",
"ko"
]
},
{

View File

@@ -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` 时只推送汇总,不含个股详情;多股时适合快速浏览(默认 falseIssue #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避免再次触发分析重算。

View File

@@ -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.

View File

@@ -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 + """

View File

@@ -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 键名保持不变,所有面向用户的文本值使用中文)")

View File

@@ -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]:

View File

@@ -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":
if advice_key == "range":
if language == "zh" and "震荡" not in str(result.trend_prediction):
result.trend_prediction = "震荡"
elif language == "en" and advice_key == "range":
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 KeyGEMINI_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 KeyGEMINI_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',

View File

@@ -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": [

View File

@@ -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 "大盘复盘报告已生成。"

View File

@@ -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

View File

@@ -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", "높음"}

View File

@@ -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}
---

View File

@@ -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]

View File

@@ -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"

View File

@@ -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)

View File

@@ -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(

View File

@@ -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 = (
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."
if language == "en"
else "当前处于盘中阶段,应以实时状态、观察条件和下一次检查点为准,避免盘后复盘口径。"
),
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:

View File

@@ -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:

View File

@@ -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",

View File

@@ -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(

View File

@@ -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")

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"}]