Skip to content

Commit d2c570d

Browse files
authored
Fix public status and AI consistency (#22)
Keep AI candidate copy, publication status, and public repository metadata aligned with the actual system behavior.
1 parent 2f21b1c commit d2c570d

8 files changed

Lines changed: 212 additions & 9 deletions

File tree

assets/scripts/v2/render/candidateCard.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,11 +284,14 @@ function quantChips(item) {
284284
return chips;
285285
}
286286

287-
function quantBlockHtml(item) {
287+
function quantBlockHtml(item, status = 'ai-none') {
288288
const chips = quantChips(item);
289+
const hasAiAnalysis = status !== 'ai-none';
289290
if (!chips.length) {
290291
return `<div class="ai-none-block">
291-
<p>本期没有 AI 个股分析,数据中也没有可展示的量化信号明细。</p>
292+
<p>${hasAiAnalysis
293+
? 'AI 分析已展示;本期没有可公开的量化信号明细。'
294+
: '本期没有 AI 个股分析,数据中也没有可展示的量化信号明细。'}</p>
292295
</div>`;
293296
}
294297
const hasChipLevels = (item.chip_support !== null && item.chip_support !== undefined)
@@ -297,7 +300,9 @@ function quantBlockHtml(item) {
297300
? '<p class="help-text">筹码支撑/压力位为相对持仓成本中枢的比值,1.00 代表成本中枢价位。</p>'
298301
: '';
299302
return `<div class="ai-none-block">
300-
<p>本期没有 AI 个股分析,以下为量化模型的真实信号读数:</p>
303+
<p>${hasAiAnalysis
304+
? '以下为量化模型的原始信号读数,用于与上方 AI 分析分开核对:'
305+
: '本期没有 AI 个股分析,以下为量化模型的真实信号读数:'}</p>
301306
${chipList(chips)}
302307
${footnote}
303308
</div>`;
@@ -378,7 +383,7 @@ function pointsToText(points) {
378383

379384
function unifiedAnalysisPanel(item, execution) {
380385
const status = aiStatusOf(item);
381-
const evidence = quantBlockHtml(item);
386+
const evidence = quantBlockHtml(item, status);
382387
const risks = aiRisksHtml(item);
383388
return `${actionChainHtml(item)}
384389
${aiConclusionHtml(item, status)}

data/latest/system_verdict.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"pipeline_status": {
99
"selection_ok": true,
1010
"research_ok": false,
11-
"publish_ok": false,
11+
"publish_ok": true,
1212
"orchestrator_ok": false,
1313
"lifecycle_label": "verified_observe_alias",
1414
"execution_authority": "observe_only_no_auto_order",
@@ -130,7 +130,7 @@
130130
"pipeline_status": {
131131
"selection_ok": true,
132132
"research_ok": false,
133-
"publish_ok": false,
133+
"publish_ok": true,
134134
"orchestrator_ok": false,
135135
"lifecycle_label": "verified_observe_alias",
136136
"execution_authority": "observe_only_no_auto_order",
@@ -245,4 +245,4 @@
245245
"review_state": "data/latest/review_state.json",
246246
"research_state": "data/latest/research_state.json"
247247
}
248-
}
248+
}

docs/ARCHITECTURE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ GitHub
5959

6060
发布脚本先在本机生成完整内部结果,再生成公开摘要。合同校验通过后,边界检查器删除本机明细,并逐个核对剩余数据文件是否在白名单内。审计失败时不会提交或推送。
6161

62+
`run_manifest.published` 是公开发布事实的权威字段。`system_verdict` 中冗余的 `pipeline_status.publish_ok` 会在公开边界准备阶段按运行清单、合同校验与 AI 发布就绪状态同步;审计会拒绝两处状态不一致的结果。该字段只描述发布链是否成功,不代表策略已经验证有效,也不改变 `research_ok` 或只观察执行权限。
63+
6264
## 5. 目录说明
6365

6466
- `system/src/orchestrator/`:股票池、双轨策略、推荐仓、评价合同与发布控制

scripts/build_pages_artifact.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
if str(REPO_ROOT) not in sys.path:
2424
sys.path.insert(0, str(REPO_ROOT))
2525

26-
from enforce_public_boundary import audit_public_tree # noqa: E402
26+
from enforce_public_boundary import audit_public_tree, reconcile_public_status_contract # noqa: E402
2727
from sanitize_published_data import sanitize_value # noqa: E402
2828

2929

@@ -133,6 +133,7 @@ def build_site(source: Path, output: Path) -> dict[str, Any]:
133133
if missing_required:
134134
raise ArtifactBuildError(f"artifact is missing required files: {missing_required}")
135135

136+
status_reconciliation = reconcile_public_status_contract(output)
136137
audit = audit_public_tree(output, allowed_data_paths=allowlist)
137138
if not audit["ok"]:
138139
raise ArtifactBuildError(
@@ -145,6 +146,7 @@ def build_site(source: Path, output: Path) -> dict[str, Any]:
145146
"root_file_count": len(copied_root_files),
146147
"data_file_count": len(copied_data_files),
147148
"missing_optional_data": missing_optional_data,
149+
"status_reconciliation": status_reconciliation,
148150
"audit": audit,
149151
}
150152

scripts/enforce_public_boundary.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,100 @@ def _read_allowlist(root: Path) -> Set[str]:
8383
}
8484

8585

86+
def _load_json_object(path: Path) -> dict[str, Any] | None:
87+
if not path.is_file():
88+
return None
89+
try:
90+
payload = json.loads(path.read_text(encoding="utf-8"))
91+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
92+
return None
93+
return payload if isinstance(payload, dict) else None
94+
95+
96+
def _publication_status_contract(root: Path) -> dict[str, Any]:
97+
"""Compare redundant public publication flags with the authoritative manifest."""
98+
99+
manifest_path = root / "data/latest/run_manifest.json"
100+
verdict_path = root / "data/latest/system_verdict.json"
101+
manifest = _load_json_object(manifest_path)
102+
verdict = _load_json_object(verdict_path)
103+
if manifest is None or verdict is None:
104+
return {"checked": False, "reason": "status contracts are not both present"}
105+
106+
top_status = verdict.get("pipeline_status")
107+
run = verdict.get("run")
108+
run_status = run.get("pipeline_status") if isinstance(run, dict) else None
109+
status_objects = {
110+
"system_verdict.pipeline_status": top_status,
111+
"system_verdict.run.pipeline_status": run_status,
112+
}
113+
status_objects = {
114+
key: value for key, value in status_objects.items() if isinstance(value, dict)
115+
}
116+
if not status_objects:
117+
return {"checked": False, "reason": "system verdict has no public pipeline status"}
118+
119+
expected = bool(
120+
manifest.get("validation_ok")
121+
and manifest.get("publish_ready")
122+
and manifest.get("published")
123+
)
124+
source_lineage = verdict.get("source_lineage")
125+
readiness = (
126+
source_lineage.get("ai_publish_readiness")
127+
if isinstance(source_lineage, dict)
128+
else None
129+
)
130+
if isinstance(readiness, dict):
131+
if "ok" in readiness:
132+
expected = expected and bool(readiness.get("ok"))
133+
if "published" in readiness:
134+
expected = expected and bool(readiness.get("published"))
135+
136+
actual = {
137+
key: value.get("publish_ok") for key, value in status_objects.items()
138+
}
139+
return {
140+
"checked": True,
141+
"expected_publish_ok": expected,
142+
"actual_publish_ok": actual,
143+
"manifest_path": str(manifest_path),
144+
"verdict_path": str(verdict_path),
145+
"verdict": verdict,
146+
"status_objects": status_objects,
147+
}
148+
149+
150+
def reconcile_public_status_contract(root: Path) -> dict[str, Any]:
151+
"""Synchronize public publish flags without changing strategy-effectiveness flags."""
152+
153+
root = root.resolve()
154+
contract = _publication_status_contract(root)
155+
if not contract.get("checked"):
156+
return contract
157+
158+
expected = bool(contract["expected_publish_ok"])
159+
changed_fields: list[str] = []
160+
for location, status in contract["status_objects"].items():
161+
if status.get("publish_ok") is not expected:
162+
status["publish_ok"] = expected
163+
changed_fields.append(f"{location}.publish_ok")
164+
165+
if changed_fields:
166+
verdict_path = Path(contract["verdict_path"])
167+
verdict_path.write_text(
168+
json.dumps(contract["verdict"], ensure_ascii=False, indent=2) + "\n",
169+
encoding="utf-8",
170+
)
171+
172+
return {
173+
"checked": True,
174+
"expected_publish_ok": expected,
175+
"changed": bool(changed_fields),
176+
"changed_fields": changed_fields,
177+
}
178+
179+
86180
def _walk_json(value: Any, location: str, violations: list[str]) -> None:
87181
if isinstance(value, dict):
88182
for key, child in value.items():
@@ -122,7 +216,13 @@ def prepare_public_tree(root: Path) -> dict[str, Any]:
122216
elif target.exists() or target.is_symlink():
123217
removed.append(relative)
124218
target.unlink()
125-
return {"ok": True, "removed_count": len(removed), "removed": sorted(removed)}
219+
status_reconciliation = reconcile_public_status_contract(root)
220+
return {
221+
"ok": True,
222+
"removed_count": len(removed),
223+
"removed": sorted(removed),
224+
"status_reconciliation": status_reconciliation,
225+
}
126226

127227

128228
def audit_public_tree(
@@ -175,6 +275,16 @@ def audit_public_tree(
175275
f"{_repo_path(root, path)}: literal credential is not publishable"
176276
)
177277

278+
status_contract = _publication_status_contract(root)
279+
if status_contract.get("checked"):
280+
expected = bool(status_contract["expected_publish_ok"])
281+
for location, actual in status_contract["actual_publish_ok"].items():
282+
if actual is not expected:
283+
violations.append(
284+
f"{location}.publish_ok={actual!r}: expected {expected!r} "
285+
"from run_manifest and ai_publish_readiness"
286+
)
287+
178288
unique = sorted(set(violations))
179289
return {
180290
"ok": not unique,

tests/render.test.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { RENDERERS } from '../assets/scripts/v2/render/views.js';
2727
import { buildModel, computeStaleness, getSessionMode } from '../assets/scripts/v2/data/model.js';
2828
import { SOURCES, VIEW_DEPS } from '../assets/scripts/v2/data/manifest.js';
2929
import { escapeHtml, pctHtml, dateCn } from '../assets/scripts/v2/render/format.js';
30+
import { renderCandidateAnalysis } from '../assets/scripts/v2/render/candidateCard.js';
3031

3132
// ---------------------------------------------------------------------------
3233
// 基础设施
@@ -141,6 +142,25 @@ check('candidates: AI 全空时必须显性标注「无 AI 分析」', () => {
141142
assert.ok(html.includes('均无 AI 个股分析'), '缺少整组 AI 覆盖度的如实说明');
142143
});
143144

145+
check('candidate card: 有 AI 分析时量化证据区不得声称「没有 AI 个股分析」', () => {
146+
const html = renderCandidateAnalysis({
147+
strategy_id: 'prebreakout_v41',
148+
code: '600000',
149+
name: '测试标的',
150+
industry_name: '测试行业',
151+
score: 82.5,
152+
ai_summary: '真实 AI 分析摘要。',
153+
ai_points: ['风险与触发条件已核对。'],
154+
ai_risks: ['测试风险。'],
155+
winner_rate: 55.0,
156+
chip_conc: 0.05,
157+
role_type: 'watch'
158+
});
159+
assert.ok(html.includes('AI 已分析'), '有真实 AI 内容时应显示 AI 已分析');
160+
assert.ok(html.includes('用于与上方 AI 分析分开核对'), '量化证据区应说明与 AI 结论分开核对');
161+
assert.ok(!html.includes('本期没有 AI 个股分析'), '不得同时声称没有 AI 个股分析');
162+
});
163+
144164
check('candidates: 禁止 v2 硬编码业绩数字(4.44 / -1.74% / 60%)', () => {
145165
const html = RENDERERS.candidates(model);
146166
const found = findHardcodedNumber(html);

tests/test_pages_artifact.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ def test_build_contains_only_sanitized_allowlisted_results(self):
2828
self.assertFalse((output / "data/latest/review_state_unified.json").exists())
2929
self.assertFalse((output / "data/latest/factor_attribution_state.json").exists())
3030

31+
verdict = json.loads(
32+
(output / "data/latest/system_verdict.json").read_text(encoding="utf-8")
33+
)
34+
self.assertTrue(verdict["pipeline_status"]["publish_ok"])
35+
self.assertTrue(verdict["run"]["pipeline_status"]["publish_ok"])
36+
3137
review = json.loads(
3238
(output / "data/latest/review_state.json").read_text(encoding="utf-8")
3339
)

tests/test_public_boundary.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,64 @@ def test_prepare_removes_private_artifacts_and_preserves_results(self):
124124
self.assertTrue(result_path.exists())
125125
self.assertGreaterEqual(report["removed_count"], 3)
126126

127+
def test_publication_status_is_reconciled_and_then_passes_audit(self):
128+
with tempfile.TemporaryDirectory() as tmpdir:
129+
root = Path(tmpdir)
130+
latest = root / "data/latest"
131+
latest.mkdir(parents=True)
132+
manifest_path = latest / "run_manifest.json"
133+
verdict_path = latest / "system_verdict.json"
134+
manifest_path.write_text(
135+
json.dumps(
136+
{
137+
"validation_ok": True,
138+
"publish_ready": True,
139+
"published": True,
140+
}
141+
),
142+
encoding="utf-8",
143+
)
144+
verdict_path.write_text(
145+
json.dumps(
146+
{
147+
"run": {"pipeline_status": {"publish_ok": False}},
148+
"pipeline_status": {"publish_ok": False},
149+
"source_lineage": {
150+
"ai_publish_readiness": {"ok": True, "published": True}
151+
},
152+
}
153+
),
154+
encoding="utf-8",
155+
)
156+
allowlist = {
157+
"data/latest/run_manifest.json",
158+
"data/latest/system_verdict.json",
159+
}
160+
161+
before = boundary.audit_public_tree(root, allowed_data_paths=allowlist)
162+
report = boundary.prepare_public_tree(root)
163+
after = boundary.audit_public_tree(root, allowed_data_paths=allowlist)
164+
verdict = json.loads(verdict_path.read_text(encoding="utf-8"))
165+
166+
self.assertFalse(before["ok"])
167+
self.assertTrue(report["status_reconciliation"]["changed"])
168+
self.assertTrue(verdict["pipeline_status"]["publish_ok"])
169+
self.assertTrue(verdict["run"]["pipeline_status"]["publish_ok"])
170+
self.assertTrue(after["ok"], after)
171+
172+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
173+
manifest["published"] = False
174+
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
175+
176+
unpublished_report = boundary.prepare_public_tree(root)
177+
unpublished_verdict = json.loads(verdict_path.read_text(encoding="utf-8"))
178+
unpublished_audit = boundary.audit_public_tree(root, allowed_data_paths=allowlist)
179+
180+
self.assertTrue(unpublished_report["status_reconciliation"]["changed"])
181+
self.assertFalse(unpublished_verdict["pipeline_status"]["publish_ok"])
182+
self.assertFalse(unpublished_verdict["run"]["pipeline_status"]["publish_ok"])
183+
self.assertTrue(unpublished_audit["ok"], unpublished_audit)
184+
127185
def test_legacy_root_data_files_are_rejected(self):
128186
with tempfile.TemporaryDirectory() as tmpdir:
129187
root = Path(tmpdir)

0 commit comments

Comments
 (0)