Compare commits

...
2 Commits
Author SHA1 Message Date
Blizzard d65b6bc84f Merge pull request 'fix: retain stock identities in research and AI task lists' (#8) from dev into master
Veyra CI/CD / verify (push) Successful in 5m39s
Veyra CI/CD / deploy (push) Successful in 3m14s
2026-10-09 15:08:59 +00:00
Blizzard c8c61d29bf fix: retain stock identities in research and AI task lists 2026-10-09 23:08:47 +08:00
9 changed files with 29 additions and 7 deletions
+2 -1
View File
@@ -19,7 +19,8 @@
- 实际 HTTPS 查询复现:名称“诺普信”无结果,但代码 002215 和名称“诺 普 信”均返回该深交所股票。数据源在名称中填充了空格,原 SQL LIKE 连续匹配漏掉无空格查询,证券并未漏同步。 - 实际 HTTPS 查询复现:名称“诺普信”无结果,但代码 002215 和名称“诺 普 信”均返回该深交所股票。数据源在名称中填充了空格,原 SQL LIKE 连续匹配漏掉无空格查询,证券并未漏同步。
- 查询统一忽略名称与输入中的空格、制表符和全角空格,保持原始源名称;转义 LIKE 通配符为字面匹配,避免输入 % 或 _ 返回全部股票。管理端无搜索结果文案与无同步数据区分。真实 PostgreSQL 隔离 schema 的 race 回归覆盖名称/代码/空格和通配符,go vet、管理端类型检查/build/embed 通过;已通过 PR #6 合并,待 CI 上线后实查。 - 查询统一忽略名称与输入中的空格、制表符和全角空格,保持原始源名称;转义 LIKE 通配符为字面匹配,避免输入 % 或 _ 返回全部股票。管理端无搜索结果文案与无同步数据区分。真实 PostgreSQL 隔离 schema 的 race 回归覆盖名称/代码/空格和通配符,go vet、管理端类型检查/build/embed 通过;已通过 PR #6 合并,待 CI 上线后实查。
- 按用户补充要求,管理端行情列表/详情、候选队列、审核详情/确认、总览观察与信号卡将代码与名称并列展示,代码使用等宽字形;选股、回测、策略证券列表以及 App 候选/计划/持仓/成交原已有代码,继续保留。管理端统一 StockIdentity 组件,类型检查/build/embed 通过。 - 按用户补充要求,管理端行情列表/详情、候选队列、审核详情/确认、总览观察与信号卡将代码与名称并列展示,代码使用等宽字形;选股、回测详情、策略证券列表以及 App 候选/计划/持仓/成交原已有代码,继续保留。另补齐回测任务列表与 AI 任务执行中/失败时的股票身份:代码从不可覆盖输入/候选快照读取,不等待模型输出。管理端统一 StockIdentity 组件,类型检查/build/embed 通过。
- 完整 Go race 与 go vet、管理端类型检查/build/embed 通过;新增列表集成检查保留证券代码且不返回完整回测输入。真实 gpt-6.1-sol 使用中洲控股的现有日线完成分析与独立复核(约 40 秒合计),两次结构化输出及证据索引校验通过;仅只读连接验证,没有写入 AI 发布事实。
- 用户最新指示先不处理 App 安装,保留已有 IPA 产物;当前优先验证名称搜索、股票代码展示与模型运行配置。 - 用户最新指示先不处理 App 安装,保留已有 IPA 产物;当前优先验证名称搜索、股票代码展示与模型运行配置。
## App Expo Go 真机入口(2026-10-09) ## App Expo Go 真机入口(2026-10-09)
@@ -129,6 +129,11 @@ func testCandidateAI(t *testing.T, ctx context.Context, pool *pgxpool.Pool, admi
if err != nil || len(results) != 4 { if err != nil || len(results) != 4 {
t.Fatal("durable results missing", err, len(results)) t.Fatal("durable results missing", err, len(results))
} }
for _, result := range results {
if result.Name != "Fixture" || result.Symbol != "000042" || result.Exchange != "SZSE" {
t.Fatal("analysis task lost candidate identity")
}
}
} }
type rejectModel struct{ calls int } type rejectModel struct{ calls int }
@@ -95,6 +95,10 @@ func testStrategyBacktest(t *testing.T, ctx context.Context, pool *pgxpool.Pool,
if e != nil || done.Status != "COMPLETED" || done.FinishedAt == nil || len(done.Result) == 0 { if e != nil || done.Status != "COMPLETED" || done.FinishedAt == nil || len(done.Result) == 0 {
t.Fatalf("completed job: %v", e) t.Fatalf("completed job: %v", e)
} }
listed, e := repo.List(ctx)
if e != nil || len(listed) != 1 || listed[0].Symbol != bi.Symbol || listed[0].Input != nil || listed[0].Result != nil {
t.Fatal("backtest list must retain the symbol without returning large inputs")
}
if _, e := pool.Exec(ctx, `UPDATE veyra_backtest_run SET result='{}'::jsonb WHERE id=$1`, job.ID); e == nil { if _, e := pool.Exec(ctx, `UPDATE veyra_backtest_run SET result='{}'::jsonb WHERE id=$1`, job.ID); e == nil {
t.Fatal("completed result can be overwritten") t.Fatal("completed result can be overwritten")
} }
+1
View File
@@ -27,6 +27,7 @@ type ResearchInput struct {
Parameters StrategyParameters `json:"parameters"` Parameters StrategyParameters `json:"parameters"`
} }
type BacktestRun struct { type BacktestRun struct {
Symbol string `json:"symbol"`
ID string `db:"id" json:"id"` ID string `db:"id" json:"id"`
StrategyVersionID string `db:"strategy_version_id" json:"strategy_version_id"` StrategyVersionID string `db:"strategy_version_id" json:"strategy_version_id"`
Status string `db:"status" json:"status"` Status string `db:"status" json:"status"`
+3
View File
@@ -23,6 +23,9 @@ type AIConclusion struct {
MissingInformation []string `json:"missing_information"` MissingInformation []string `json:"missing_information"`
} }
type CandidateAnalysis struct { type CandidateAnalysis struct {
Name string `json:"name,omitempty"`
Symbol string `json:"symbol,omitempty"`
Exchange string `json:"exchange,omitempty"`
ID string `db:"id" json:"id"` ID string `db:"id" json:"id"`
CandidateRevisionID string `db:"candidate_revision_id" json:"candidate_revision_id"` CandidateRevisionID string `db:"candidate_revision_id" json:"candidate_revision_id"`
InputVersion int64 `db:"input_version" json:"input_version"` InputVersion int64 `db:"input_version" json:"input_version"`
+7
View File
@@ -21,6 +21,13 @@ const backtestColumns = `id,strategy_version_id,status,input,input_hash,result,f
func scanBacktest(row pgx.Row) (model.BacktestRun, error) { func scanBacktest(row pgx.Row) (model.BacktestRun, error) {
var v model.BacktestRun var v model.BacktestRun
err := row.Scan(&v.ID, &v.StrategyVersionID, &v.Status, &v.Input, &v.InputHash, &v.Result, &v.Failure, &v.LeaseID, &v.CreatedAt, &v.FinishedAt) err := row.Scan(&v.ID, &v.StrategyVersionID, &v.Status, &v.Input, &v.InputHash, &v.Result, &v.Failure, &v.LeaseID, &v.CreatedAt, &v.FinishedAt)
if err == nil {
var identity struct {
Symbol string `json:"symbol"`
}
err = json.Unmarshal(v.Input, &identity)
v.Symbol = identity.Symbol
}
return v, mapError(err) return v, mapError(err)
} }
func (r *BacktestRepository) Detail(ctx context.Context, id string) (model.BacktestRun, error) { func (r *BacktestRepository) Detail(ctx context.Context, id string) (model.BacktestRun, error) {
+2 -2
View File
@@ -183,7 +183,7 @@ func (r *CandidateAnalysisRepository) Complete(ctx context.Context, p model.AIRe
return mapError(tx.Commit(ctx)) return mapError(tx.Commit(ctx))
} }
func (r *CandidateAnalysisRepository) List(ctx context.Context) ([]model.CandidateAnalysis, error) { func (r *CandidateAnalysisRepository) List(ctx context.Context) ([]model.CandidateAnalysis, error) {
rows, err := r.pool.Query(ctx, `SELECT id,candidate_revision_id,input_version,policy_version,status,attempts,primary_model,review_model,primary_version,review_version,analysis,review,message,created_at,updated_at FROM veyra_candidate_analysis ORDER BY updated_at DESC LIMIT 100`) rows, err := r.pool.Query(ctx, `SELECT a.id,a.candidate_revision_id,a.input_version,a.policy_version,a.status,a.attempts,a.primary_model,a.review_model,a.primary_version,a.review_version,a.analysis,a.review,a.message,a.created_at,a.updated_at,r.snapshot->>'name',r.snapshot->>'symbol',r.snapshot->>'exchange' FROM veyra_candidate_analysis a JOIN veyra_candidate_revision r ON r.id=a.candidate_revision_id ORDER BY a.updated_at DESC LIMIT 100`)
if err != nil { if err != nil {
return nil, mapError(err) return nil, mapError(err)
} }
@@ -192,7 +192,7 @@ func (r *CandidateAnalysisRepository) List(ctx context.Context) ([]model.Candida
for rows.Next() { for rows.Next() {
var v model.CandidateAnalysis var v model.CandidateAnalysis
var a, b []byte var a, b []byte
if err = rows.Scan(&v.ID, &v.CandidateRevisionID, &v.InputVersion, &v.PolicyVersion, &v.Status, &v.Attempts, &v.PrimaryModel, &v.ReviewModel, &v.PrimaryVersion, &v.ReviewVersion, &a, &b, &v.Message, &v.CreatedAt, &v.UpdatedAt); err != nil { if err = rows.Scan(&v.ID, &v.CandidateRevisionID, &v.InputVersion, &v.PolicyVersion, &v.Status, &v.Attempts, &v.PrimaryModel, &v.ReviewModel, &v.PrimaryVersion, &v.ReviewVersion, &a, &b, &v.Message, &v.CreatedAt, &v.UpdatedAt, &v.Name, &v.Symbol, &v.Exchange); err != nil {
return nil, mapError(err) return nil, mapError(err)
} }
if len(a) > 0 { if len(a) > 0 {
+3 -2
View File
@@ -1,3 +1,4 @@
import {StockIdentity} from "./StockIdentity"
import {useEffect,useState} from "react" import {useEffect,useState} from "react"
import {request,ApiError} from "@/lib/auth" import {request,ApiError} from "@/lib/auth"
import {Button} from "@/components/ui/button" import {Button} from "@/components/ui/button"
@@ -5,7 +6,7 @@ import {Badge} from "@/components/ui/badge"
import {Card,CardHeader,CardTitle,CardContent,CardDescription} from "@/components/ui/card" import {Card,CardHeader,CardTitle,CardContent,CardDescription} from "@/components/ui/card"
type Policy={enabled:boolean;auto_publish:boolean;version:number} type Policy={enabled:boolean;auto_publish:boolean;version:number}
type Conclusion={symbol:string;decision:string;summary:string;risks:string;missing_information:string[]} type Conclusion={symbol:string;decision:string;summary:string;risks:string;missing_information:string[]}
type Analysis={id:string;status:string;attempts:number;message:string;primary_model:string;review_model:string;analysis?:Conclusion;review?:Conclusion;updated_at:string} type Analysis={id:string;name:string;symbol:string;exchange:string;status:string;attempts:number;message:string;primary_model:string;review_model:string;analysis?:Conclusion;review?:Conclusion;updated_at:string}
const states:Record<string,string>={RUNNING:"分析中",FAILED:"调用失败",APPROVED:"已通过 · 等待发布",REJECTED:"AI 未通过",PUBLISHED:"已自动发布",STALE:"版本或规则已失效"} const states:Record<string,string>={RUNNING:"分析中",FAILED:"调用失败",APPROVED:"已通过 · 等待发布",REJECTED:"AI 未通过",PUBLISHED:"已自动发布",STALE:"版本或规则已失效"}
export function AIReviewPanel({onSessionExpired}:{onSessionExpired:()=>void}){ export function AIReviewPanel({onSessionExpired}:{onSessionExpired:()=>void}){
const [policy,setPolicy]=useState<Policy|null>(null),[results,setResults]=useState<Analysis[]>([]),[enabled,setEnabled]=useState(false),[auto,setAuto]=useState(false),[error,setError]=useState(""),[busy,setBusy]=useState(false),[notice,setNotice]=useState("") const [policy,setPolicy]=useState<Policy|null>(null),[results,setResults]=useState<Analysis[]>([]),[enabled,setEnabled]=useState(false),[auto,setAuto]=useState(false),[error,setError]=useState(""),[busy,setBusy]=useState(false),[notice,setNotice]=useState("")
@@ -13,5 +14,5 @@ export function AIReviewPanel({onSessionExpired}:{onSessionExpired:()=>void}){
useEffect(()=>{const c=new AbortController();request<Policy>("/api/admin/v1/ai-review-policy",{signal:c.signal}).then(p=>{setPolicy(p);setEnabled(p.enabled);setAuto(p.auto_publish)}).catch(e=>{if(!c.signal.aborted)failure(e)});const load=()=>request<Analysis[]>("/api/admin/v1/candidate-analyses",{signal:c.signal}).then(setResults).catch(e=>{if(!c.signal.aborted)failure(e)});void load();const timer=setInterval(()=>void load(),5000);return()=>{c.abort();clearInterval(timer)}},[onSessionExpired]) useEffect(()=>{const c=new AbortController();request<Policy>("/api/admin/v1/ai-review-policy",{signal:c.signal}).then(p=>{setPolicy(p);setEnabled(p.enabled);setAuto(p.auto_publish)}).catch(e=>{if(!c.signal.aborted)failure(e)});const load=()=>request<Analysis[]>("/api/admin/v1/candidate-analyses",{signal:c.signal}).then(setResults).catch(e=>{if(!c.signal.aborted)failure(e)});void load();const timer=setInterval(()=>void load(),5000);return()=>{c.abort();clearInterval(timer)}},[onSessionExpired])
async function save(){if(!policy)return;setBusy(true);setError("");try{const p=await request<Policy>("/api/admin/v1/ai-review-policy",{method:"PUT",body:JSON.stringify({enabled,auto_publish:auto,expected_version:policy.version})});setPolicy(p);setNotice("审核策略已保存,后台会处理待审核候选。")}catch(e){failure(e)}finally{setBusy(false)}} async function save(){if(!policy)return;setBusy(true);setError("");try{const p=await request<Policy>("/api/admin/v1/ai-review-policy",{method:"PUT",body:JSON.stringify({enabled,auto_publish:auto,expected_version:policy.version})});setPolicy(p);setNotice("审核策略已保存,后台会处理待审核候选。")}catch(e){failure(e)}finally{setBusy(false)}}
async function run(){setBusy(true);setError("");try{await request("/api/admin/v1/candidate-analyses/run",{method:"POST"});setNotice("已通知后台处理;结果自动刷新。")}catch(e){failure(e)}finally{setBusy(false)}} async function run(){setBusy(true);setError("");try{await request("/api/admin/v1/candidate-analyses/run",{method:"POST"});setNotice("已通知后台处理;结果自动刷新。")}catch(e){failure(e)}finally{setBusy(false)}}
return <Card><CardHeader><CardTitle>AI 候选分析与自动审核</CardTitle><CardDescription>策略筛选 → 主模型分析 → 审核模型复核 → 服务端规则校验 → App 发布。仅限模拟研究;不会生成计划或成交。</CardDescription></CardHeader><CardContent className="space-y-5"><div className="flex flex-wrap gap-5"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} disabled={busy||!policy} onChange={e=>setEnabled(e.target.checked)}/>启用 AI 分析与审核</label><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={auto} disabled={busy||!policy} onChange={e=>setAuto(e.target.checked)}/>审核与规则通过后自动发布</label></div><p className="text-sm text-muted-foreground">主模型和审核模型必须配置并启用。关闭自动发布时保留 AI 意见供人工复核;失败最多重试三次,候选过期不发布。</p><div className="flex gap-3"><Button disabled={busy||!policy} onClick={()=>void save()}>保存审核策略</Button><Button variant="outline" disabled={busy||!policy?.enabled} onClick={()=>void run()}>立即处理待审核候选</Button></div>{notice&&<p role="status" className="text-sm">{notice}</p>}{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}<div className="space-y-3">{results.length===0&&<p className="text-sm text-muted-foreground">暂无 AI 任务。启用后自动处理有效待审核候选。</p>}{results.map(a=><div key={a.id} className="rounded-xl border p-4 space-y-2"><div className="flex justify-between gap-3"><strong>{a.analysis?.symbol??"候选分析任务"}</strong><Badge variant="outline">{states[a.status]??a.status}</Badge></div><p className="text-sm">{a.message||"正在调用模型…"}</p>{a.analysis&&<><p className="text-sm leading-6">分析:{a.analysis.summary}</p><p className="text-sm text-muted-foreground">风险:{a.analysis.risks}</p></>}{a.review&&<p className="text-sm leading-6">复核:{a.review.summary}</p>}<p className="text-xs text-muted-foreground">{a.primary_model||"待调用"} / {a.review_model||"待调用"} · 尝试 {a.attempts} · {new Date(a.updated_at).toLocaleString("zh-CN")}</p></div>)}</div></CardContent></Card> return <Card><CardHeader><CardTitle>AI 候选分析与自动审核</CardTitle><CardDescription>策略筛选 → 主模型分析 → 审核模型复核 → 服务端规则校验 → App 发布。仅限模拟研究;不会生成计划或成交。</CardDescription></CardHeader><CardContent className="space-y-5"><div className="flex flex-wrap gap-5"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} disabled={busy||!policy} onChange={e=>setEnabled(e.target.checked)}/>启用 AI 分析与审核</label><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={auto} disabled={busy||!policy} onChange={e=>setAuto(e.target.checked)}/>审核与规则通过后自动发布</label></div><p className="text-sm text-muted-foreground">主模型和审核模型必须配置并启用。关闭自动发布时保留 AI 意见供人工复核;失败最多重试三次,候选过期不发布。</p><div className="flex gap-3"><Button disabled={busy||!policy} onClick={()=>void save()}>保存审核策略</Button><Button variant="outline" disabled={busy||!policy?.enabled} onClick={()=>void run()}>立即处理待审核候选</Button></div>{notice&&<p role="status" className="text-sm">{notice}</p>}{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}<div className="space-y-3">{results.length===0&&<p className="text-sm text-muted-foreground">暂无 AI 任务。启用后自动处理有效待审核候选。</p>}{results.map(a=><div key={a.id} className="rounded-xl border p-4 space-y-2"><div className="flex justify-between gap-3"><strong><StockIdentity name={a.name} symbol={a.symbol}/></strong><Badge variant="outline">{states[a.status]??a.status}</Badge></div><p className="text-sm">{a.message||"正在调用模型…"}</p>{a.analysis&&<><p className="text-sm leading-6">分析:{a.analysis.summary}</p><p className="text-sm text-muted-foreground">风险:{a.analysis.risks}</p></>}{a.review&&<p className="text-sm leading-6">复核:{a.review.summary}</p>}<p className="text-xs text-muted-foreground">{a.primary_model||"待调用"} / {a.review_model||"待调用"} · 尝试 {a.attempts} · {new Date(a.updated_at).toLocaleString("zh-CN")}</p></div>)}</div></CardContent></Card>
} }
File diff suppressed because one or more lines are too long