Skip to content

Commit 572aa49

Browse files
Pigbibicodex
andcommitted
feat(watcher): add bounded AI research diagnosis
Co-Authored-By: Codex <noreply@openai.com>
1 parent be460ff commit 572aa49

11 files changed

Lines changed: 746 additions & 2 deletions

.github/workflows/strategy_optimization_watcher.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ permissions:
3535
contents: read
3636
actions: read
3737
issues: write
38+
id-token: write
3839

3940
concurrency:
4041
group: strategy-optimization-watcher-${{ github.event.inputs.source_repo || vars.STRATEGY_WATCH_SOURCE_REPO || 'QuantStrategyLab/CryptoLivePoolPipelines' }}
@@ -236,6 +237,21 @@ jobs:
236237
mkdir -p data/output/strategy_optimization_watcher
237238
python scripts/run_strategy_optimization_watcher.py | tee data/output/strategy_optimization_watcher/result.json
238239
240+
- name: Run one bounded AI research diagnosis
241+
if: steps.fetch-metrics.outputs.downloaded == 'true' && github.event_name == 'schedule'
242+
env:
243+
CODEX_AUDIT_SERVICE_URL: ${{ secrets.CODEX_AUDIT_SERVICE_URL }}
244+
CODEX_AUDIT_SERVICE_AUDIENCE: ${{ vars.CODEX_AUDIT_SERVICE_AUDIENCE || 'quant-codex-audit' }}
245+
DEFAULT_ANALYZE_MODEL: ${{ vars.RESEARCH_DIAGNOSIS_MODEL || 'gpt-5.4-mini' }}
246+
RESEARCH_DIAGNOSIS_MAX_PER_RUN: "1"
247+
working-directory: bridge
248+
run: |
249+
set -euo pipefail
250+
python scripts/run_research_task_diagnosis.py \
251+
--input data/output/strategy_optimization_watcher/result.json \
252+
--max-per-run "${RESEARCH_DIAGNOSIS_MAX_PER_RUN}" \
253+
| tee data/output/strategy_optimization_watcher/research-diagnosis.json
254+
239255
- name: Extract bounded research task source snapshot
240256
if: steps.fetch-metrics.outputs.downloaded == 'true'
241257
working-directory: bridge

.github/workflows/vps_codex_service_ops.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ jobs:
5757
CODEX_AUDIT_SSH_UNBAN_IP: ${{ inputs.ssh_unban_ip }}
5858
CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES: QuantStrategyLab/AIAuditBridge,QuantStrategyLab/BinancePlatform,QuantStrategyLab/CharlesSchwabPlatform,QuantStrategyLab/CnEquitySnapshotPipelines,QuantStrategyLab/CnEquityStrategies,QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/CryptoStrategies,QuantStrategyLab/FirstradePlatform,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/HkEquityStrategies,QuantStrategyLab/IBKRGatewayManager,QuantStrategyLab/InteractiveBrokersPlatform,QuantStrategyLab/LongBridgePlatform,QuantStrategyLab/MarketSignalSources,QuantStrategyLab/PoliticalEventTrackingResearch,QuantStrategyLab/QmtPlatform,QuantStrategyLab/QuantAdvisorResearch,QuantStrategyLab/QuantPlatformKit,QuantStrategyLab/QuantRuntimeSettings,QuantStrategyLab/QuantStrategyPlugins,QuantStrategyLab/ResearchSignalContextPipelines,QuantStrategyLab/SchwabTokenAutoRefresher,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/UsEquityStrategies
5959
# workflow_dispatch emits protected-main workflow_ref claims; the deploy script pins delegated QPK code by exact job_workflow_ref SHA.
60-
CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS: QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/CnEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/UsEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/CryptoStrategies/.github/workflows/drift-check.yml@refs/heads/main
60+
CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS: QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/strategy_optimization_watcher.yml@refs/heads/main,QuantStrategyLab/CnEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/UsEquityStrategies/.github/workflows/drift-check.yml@refs/heads/main,QuantStrategyLab/CryptoStrategies/.github/workflows/drift-check.yml@refs/heads/main
6161
CODEX_AUDIT_SERVICE_ALLOWED_REFS: refs/heads/main
6262
# Exact canonical audit job plus immutable QPK `uses:` refs pinned by strategy drift callers.
6363
# Rotation tracked in #64; remove the old QPK SHA after final strategy-run verification.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ python3 -m unittest discover -s tests -v
204204
- [`docs/architecture.md`](docs/architecture.md): service components and endpoint map.
205205
- [`docs/async_service_deployment.md`](docs/async_service_deployment.md): VPS service and Worker deployment.
206206
- [`docs/health_taxonomy.md`](docs/health_taxonomy.md): dashboard, quota, workflow, job, and artifact health semantics.
207+
- [`docs/bounded_research_diagnosis.md`](docs/bounded_research_diagnosis.md): verified P3 task → one read-only AI diagnosis, with low-frequency escalation boundaries.
207208
- [`docs/ai_autonomy_architecture.md`](docs/ai_autonomy_architecture.md): AI autonomy design review and phased roadmap.
208209

209210
## Community and security

client/gateway_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def analyze(
9797
system: str = "",
9898
max_tokens: int = 4000,
9999
timeout: float | None = None,
100+
source_repository: str | None = None,
100101
) -> AiResult:
101102
"""Sync LLM completion via ``POST /v1/ai/analyze``."""
102103
selected_model = model or self.config.default_analyze_model
@@ -113,6 +114,7 @@ def analyze(
113114
"system": system,
114115
"max_tokens": max_tokens,
115116
"timeout_seconds": int(timeout),
117+
"source_repository": source_repository or self.config.source_repository,
116118
}).encode("utf-8")
117119

118120
req = urllib.request.Request(

docs/architecture.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ POST /v1/ai/analyze
9494
→ return {output, model, latency}
9595
```
9696

97+
`Strategy Optimization Watcher` may use this endpoint for one bounded,
98+
text-only diagnosis of an already verified `qsl.research_task.v1`. The
99+
diagnosis is an Issue comment, not a code change or experiment: it does not
100+
receive raw bars or credentials and cannot activate P4--P6.
101+
97102
### Execute (async)
98103
```
99104
POST /v1/ai/execute/jobs

docs/bounded_research_diagnosis.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# 自动受限研究诊断
2+
3+
状态:`IMPLEMENTED_NON_LIVE_RESEARCH_DIAGNOSIS`
4+
5+
这是 AIAuditBridge 在策略监测之后的一个小闭环,不是策略执行器。
6+
7+
## 自动做什么
8+
9+
每天的 `Strategy Optimization Watcher` 先从受信任的两个 P3 脱敏绩效
10+
工件构造比较。只有比较结果同时绑定 P1 输入摘要、P2 冻结配置摘要、P3
11+
证据 ID、策略 revision 与 producer revision 时,才会生成
12+
`qsl.research_task.v1`
13+
14+
对每个尚未诊断的 Issue,调度器每次最多处理一个任务:
15+
16+
1. 重新验证任务的完整 JSON 形状、canonical SHA-256 和固定 no-order
17+
authority;
18+
2. 调用既有 AI Gateway 的文本分析接口;
19+
3. 在原 Issue 写入一条带幂等标记的中文诊断和下一轮**离线**研究建议。
20+
21+
普通退化不发送 Telegram。运行数据不可用、证据记录失败、熔断或其他
22+
运行风险仍走 VPS quant-monitor 的去重 Telegram 路径;因此人工只收到
23+
需要及时处理的运维/风险信号,而不是每一条策略波动。
24+
25+
## 明确不做什么
26+
27+
- 不读取 raw bars、账户、凭证或订单;
28+
- 不运行回测,不修改代码、参数或配置;
29+
- 不创建 PR,不部署,不启动 paper 或 shadow;
30+
- 不授权 P4、P5 或 P6;P6 仍必须由所有者明确决定。
31+
32+
AI Gateway 不可用、任务不完整或 Issue 评论失败时,调度器不采取替代动作;
33+
已有 Issue 和受限任务仍保留为下一次计划运行的审计起点。
34+
35+
## 启用条件
36+
37+
代码合并后,VPS Codex service 必须通过受控的 `VPS Codex Service Ops`
38+
部署一次,才能将精确的
39+
`strategy_optimization_watcher.yml@refs/heads/main` OIDC 身份加入 allowlist。
40+
仓库还须已有 `CODEX_AUDIT_SERVICE_URL` secret;若不存在,调度器会记录
41+
`not_configured` 并安全跳过,不影响 watcher 的 Issue/任务索引行为。
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
#!/usr/bin/env python3
2+
"""Turn one verified watcher task into one read-only AI diagnosis comment.
3+
4+
The watcher still owns issue creation and task construction. This dispatcher
5+
only consumes a task that is already cryptographically bound to P1/P2/P3
6+
digests, asks the existing AI gateway for a text-only assessment, then adds one
7+
idempotency-marked comment to the existing issue. It cannot run an experiment
8+
or alter a strategy.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import argparse
14+
import json
15+
import os
16+
import subprocess
17+
import sys
18+
from collections.abc import Callable, Mapping
19+
from pathlib import Path
20+
from typing import Any
21+
22+
ROOT = Path(__file__).resolve().parents[1]
23+
if str(ROOT) not in sys.path:
24+
sys.path.insert(0, str(ROOT))
25+
26+
from client.config import GatewayConfig # noqa: E402
27+
from client.gateway_client import AiGatewayClient # noqa: E402
28+
from service.research_diagnosis import ( # noqa: E402
29+
MARKER,
30+
build_research_diagnosis_prompt,
31+
build_research_diagnosis_request,
32+
format_research_diagnosis_comment,
33+
)
34+
35+
36+
MAX_AUTOMATIC_DIAGNOSES = 1
37+
_REPOSITORY = "QuantStrategyLab"
38+
39+
40+
def _clean_repo(value: object) -> str:
41+
repo = str(value or "").strip()
42+
if not repo.startswith(f"{_REPOSITORY}/") or "/" not in repo:
43+
return ""
44+
return repo
45+
46+
47+
def load_watcher_result(path: str | Path) -> dict[str, Any]:
48+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
49+
if not isinstance(payload, dict):
50+
raise ValueError("watcher result must be a JSON object")
51+
return payload
52+
53+
54+
def diagnosis_candidates(result: Mapping[str, Any]) -> list[dict[str, Any]]:
55+
"""Join watcher issue results to the exact current research-task IDs."""
56+
snapshot = result.get("research_task_source_snapshot")
57+
if not isinstance(snapshot, Mapping) or snapshot.get("data_status") != "ready":
58+
return []
59+
raw_tasks = snapshot.get("tasks")
60+
raw_issues = result.get("issues")
61+
if not isinstance(raw_tasks, list) or not isinstance(raw_issues, list):
62+
return []
63+
tasks_by_id: dict[str, Mapping[str, Any]] = {}
64+
for task in raw_tasks:
65+
if isinstance(task, Mapping) and isinstance(task.get("task_id"), str):
66+
tasks_by_id[task["task_id"]] = task
67+
68+
candidates: list[dict[str, Any]] = []
69+
for issue in raw_issues:
70+
if not isinstance(issue, Mapping):
71+
continue
72+
summary = issue.get("task")
73+
if not isinstance(summary, Mapping):
74+
continue
75+
event_key = str(summary.get("event_key") or "")
76+
task = tasks_by_id.get(f"watcher-{event_key}")
77+
if task is None:
78+
continue
79+
repo = _clean_repo(issue.get("repo"))
80+
issue_url = str(issue.get("url") or issue.get("existing_url") or "").strip()
81+
trigger = summary.get("trigger") if isinstance(summary.get("trigger"), Mapping) else {}
82+
if repo and issue_url:
83+
candidates.append({"repository": repo, "issue_url": issue_url, "task": task, "trigger": trigger})
84+
return sorted(candidates, key=lambda item: (str(item["repository"]), str(item["issue_url"])))
85+
86+
87+
def issue_has_diagnosis_marker(repository: str, issue_url: str) -> bool:
88+
"""Read comments only; any retrieval error means do not repeat an action."""
89+
try:
90+
completed = subprocess.run(
91+
["gh", "issue", "view", issue_url, "--repo", repository, "--json", "comments", "--jq", ".comments[].body"],
92+
check=True,
93+
capture_output=True,
94+
text=True,
95+
timeout=30,
96+
)
97+
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
98+
return True
99+
return MARKER in completed.stdout
100+
101+
102+
def comment_issue(repository: str, issue_url: str, body: str) -> str:
103+
completed = subprocess.run(
104+
["gh", "issue", "comment", issue_url, "--repo", repository, "--body", body],
105+
check=True,
106+
capture_output=True,
107+
text=True,
108+
timeout=30,
109+
)
110+
return completed.stdout.strip()
111+
112+
113+
def _error_summary(value: object) -> str:
114+
return str(value or "").replace("\r", " ").replace("\n", " ").strip()[:300]
115+
116+
117+
def run_diagnosis(
118+
result: Mapping[str, Any],
119+
*,
120+
dry_run: bool = False,
121+
max_per_run: int = MAX_AUTOMATIC_DIAGNOSES,
122+
marker_present: Callable[[str, str], bool] = issue_has_diagnosis_marker,
123+
create_comment: Callable[[str, str, str], str] = comment_issue,
124+
client_factory: Callable[[GatewayConfig], AiGatewayClient] = AiGatewayClient,
125+
) -> dict[str, Any]:
126+
"""Diagnose at most one not-yet-diagnosed issue; failures have no side effect."""
127+
candidates = diagnosis_candidates(result)
128+
if max_per_run < 1:
129+
raise ValueError("max_per_run must be positive")
130+
pending = [
131+
item
132+
for item in candidates
133+
if not marker_present(str(item["repository"]), str(item["issue_url"]))
134+
]
135+
summary: dict[str, Any] = {
136+
"schema_version": "qsl.research_diagnosis_dispatch.v1",
137+
"status": "ok",
138+
"candidate_count": len(candidates),
139+
"pending_count": len(pending),
140+
"max_per_run": max_per_run,
141+
"dry_run": dry_run,
142+
"diagnoses": [],
143+
}
144+
if not pending:
145+
summary["status"] = "skipped"
146+
summary["reason"] = "no_pending_verified_research_task"
147+
return summary
148+
149+
try:
150+
config = GatewayConfig.from_env()
151+
except ValueError:
152+
summary["status"] = "not_configured"
153+
summary["reason"] = "ai_gateway_not_configured"
154+
return summary
155+
client = client_factory(config)
156+
for candidate in pending[:max_per_run]:
157+
task = candidate["task"]
158+
try:
159+
request = build_research_diagnosis_request(task, trigger=candidate["trigger"])
160+
prompt = build_research_diagnosis_prompt(request)
161+
except (TypeError, ValueError) as exc:
162+
summary["diagnoses"].append({"status": "rejected", "error": _error_summary(exc)})
163+
continue
164+
if dry_run:
165+
summary["diagnoses"].append(
166+
{
167+
"status": "dry_run",
168+
"task_id": request["task_id"],
169+
"task_sha256": request["task_sha256"],
170+
"repository": candidate["repository"],
171+
"issue_url": candidate["issue_url"],
172+
"prompt": prompt,
173+
}
174+
)
175+
continue
176+
ai_result = client.analyze(
177+
prompt,
178+
system="You provide bounded, read-only research diagnosis only.",
179+
max_tokens=1_600,
180+
timeout=120,
181+
source_repository=str(request["target"]["repository"]),
182+
)
183+
if not ai_result.success:
184+
summary["diagnoses"].append(
185+
{
186+
"status": "unavailable",
187+
"task_id": request["task_id"],
188+
"error": _error_summary(ai_result.error),
189+
}
190+
)
191+
continue
192+
try:
193+
body = format_research_diagnosis_comment(
194+
request,
195+
ai_result.output,
196+
provider=ai_result.provider,
197+
model=ai_result.model,
198+
)
199+
comment_url = create_comment(str(candidate["repository"]), str(candidate["issue_url"]), body)
200+
except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
201+
summary["diagnoses"].append(
202+
{
203+
"status": "comment_failed",
204+
"task_id": request["task_id"],
205+
"error": _error_summary(exc),
206+
}
207+
)
208+
continue
209+
summary["diagnoses"].append(
210+
{
211+
"status": "diagnosed",
212+
"task_id": request["task_id"],
213+
"task_sha256": request["task_sha256"],
214+
"repository": candidate["repository"],
215+
"issue_url": candidate["issue_url"],
216+
"comment_url": comment_url,
217+
}
218+
)
219+
if any(item.get("status") in {"unavailable", "comment_failed", "rejected"} for item in summary["diagnoses"]):
220+
summary["status"] = "partial_error"
221+
return summary
222+
223+
224+
def main(argv: list[str] | None = None) -> int:
225+
parser = argparse.ArgumentParser(description="Run bounded AI diagnosis for verified research tasks.")
226+
parser.add_argument("--input", required=True, help="Watcher result JSON path")
227+
parser.add_argument("--dry-run", action="store_true", help="Build the prompt but do not call AI or comment")
228+
parser.add_argument("--max-per-run", type=int, default=MAX_AUTOMATIC_DIAGNOSES)
229+
args = parser.parse_args(argv)
230+
try:
231+
result = run_diagnosis(load_watcher_result(args.input), dry_run=args.dry_run, max_per_run=args.max_per_run)
232+
except (OSError, ValueError, json.JSONDecodeError) as exc:
233+
print(json.dumps({"status": "error", "error": _error_summary(exc)}, sort_keys=True))
234+
return 2
235+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
236+
# A missing service or failed text-only diagnosis must not turn into a
237+
# strategy action or break the primary watcher. Its durable issue remains
238+
# the retry point for the next scheduled run.
239+
return 0
240+
241+
242+
if __name__ == "__main__":
243+
raise SystemExit(main())

scripts/run_strategy_optimization_watcher.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ def task_public_summary(task: Any) -> dict[str, Any]:
127127
"kind": trigger.get("kind", ""),
128128
"severity": trigger.get("severity", ""),
129129
"subject": trigger.get("subject", ""),
130+
"reason": trigger.get("reason", ""),
131+
"signals": [
132+
{"reason": str(item)}
133+
for item in trigger.get("evidence", [])
134+
if isinstance(item, str)
135+
],
130136
},
131137
"proposed_action": {
132138
"action": proposed_action.get("action", ""),
@@ -139,6 +145,7 @@ def task_public_summary(task: Any) -> dict[str, Any]:
139145
"human_review_required": gate_decision.get("human_review_required", True),
140146
},
141147
"finding_type": metadata.get("finding_type", "metric_degradation"),
148+
"event_key": metadata.get("event_key", ""),
142149
"status": payload.get("status", ""),
143150
}
144151

0 commit comments

Comments
 (0)