feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用
后端 - check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、 状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。 - 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处; 没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。 - 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型, 漏答记 missing_answer,模型临时错误只影响那一批。 - 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点; 失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。 - 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。 - 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。 - ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。 - SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。 前端 - 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、 已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。 契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、 架构 §8 / §13(SK14)、AGENTS.md 同步。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
458
server/src/bid_doctor/check/service.py
Normal file
458
server/src/bid_doctor/check/service.py
Normal file
@@ -0,0 +1,458 @@
|
||||
"""检查批次与问题处理(FR-M3-02、07、08,api-design §4.1、§4.5)。
|
||||
|
||||
- 计费(data-model §4.3):免费版不记流水;专业版 24 小时内复查免扣(记一行 0);其余在解析额度上预扣 1 次。
|
||||
预览和发起共用同一个计费函数;前端带来的 expected_charge 和服务端当下算的不一致时拒绝,不扣点(API4)。
|
||||
- 「预扣 → 建批次 → 入队」在同一个事务里(架构 §3.4)。
|
||||
- 只有最近一次检查的问题可以处理;致命问题「确认无误」也要写说明,说明同时进误报反馈(U4)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bid_doctor.audit import service as audit
|
||||
from bid_doctor.billing import service as billing
|
||||
from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation
|
||||
from bid_doctor.checklist import edit as checklist_edit
|
||||
from bid_doctor.checklist import service as checklist
|
||||
from bid_doctor.core import queue
|
||||
from bid_doctor.core.db import TenantContext
|
||||
from bid_doctor.core.errors import AppError
|
||||
from bid_doctor.core.ids import uuid7
|
||||
from bid_doctor.document import citations
|
||||
from bid_doctor.document import service as documents
|
||||
from bid_doctor.document.models import SourceDocument
|
||||
from bid_doctor.identity import service as identity
|
||||
from bid_doctor.ops import service as feedback
|
||||
from bid_doctor.project import service as projects
|
||||
from bid_doctor.project.models import Project
|
||||
|
||||
CHECK_TASK = "check_project"
|
||||
RULE_SET_VERSION = "2026.11-r0" # 第 1 步还没有规则;规则集有版本,检查批次记下当时用的版本(架构 §3.7)
|
||||
WAIVE_WINDOW = timedelta(hours=24)
|
||||
ERROR_MESSAGES = {
|
||||
"bid_text_missing": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线",
|
||||
"file_corrupted": "投标文件无法转换,可能已加密或损坏",
|
||||
"plan_limit_exceeded": "投标文件页数超出套餐上限",
|
||||
"checklist_empty": "正式清单还没有条目",
|
||||
"converter_unavailable": "文档转换服务暂时不可用",
|
||||
"model_unavailable": "模型服务暂时不可用",
|
||||
"internal_error": "系统出错了",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Billing:
|
||||
mode: str # charged / waived_recheck / free_plan
|
||||
charge: int
|
||||
waive_until: datetime | None
|
||||
|
||||
|
||||
async def billing_mode(s: AsyncSession, ctx: TenantContext, p: Project) -> Billing:
|
||||
_, plan = await billing.current_plan(s, ctx)
|
||||
if plan.code == "free":
|
||||
return Billing("free_plan", 0, None) # 免费版只限 1 个项目,项目内检查不限次数(01 §4)
|
||||
last = p.last_charged_check_at
|
||||
if last is not None and datetime.now(UTC) - last < WAIVE_WINDOW:
|
||||
return Billing("waived_recheck", 0, last + WAIVE_WINDOW) # 从上一次扣了额度的检查算起,不重新计时(P1)
|
||||
return Billing("charged", 1, None)
|
||||
|
||||
|
||||
async def _running(s: AsyncSession, project_id: UUID) -> CheckRun | None:
|
||||
return (
|
||||
await s.execute(
|
||||
select(CheckRun).where(CheckRun.project_id == project_id, CheckRun.status.in_(("queued", "running")))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
async def _tender_ready(s: AsyncSession, project_id: UUID) -> bool:
|
||||
n = (
|
||||
await s.execute(
|
||||
select(func.count()).where(
|
||||
SourceDocument.project_id == project_id,
|
||||
SourceDocument.role == "tender",
|
||||
SourceDocument.status == "ready",
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
return n > 0
|
||||
|
||||
|
||||
async def preview(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[str, Any]:
|
||||
p = await projects.get(s, ctx, project_id)
|
||||
mode = await billing_mode(s, ctx, p)
|
||||
bids = await documents.latest_bid_volumes(s, project_id)
|
||||
blockers: list[str] = []
|
||||
if p.status in ("submitted", "archived"):
|
||||
blockers.append("project_read_only")
|
||||
if not await _tender_ready(s, project_id):
|
||||
blockers.append("no_parsed_tender")
|
||||
elif not await checklist.items(s, project_id, zone="formal"):
|
||||
blockers.append("checklist_empty")
|
||||
if not bids:
|
||||
blockers.append("no_bid_documents")
|
||||
if await _running(s, project_id):
|
||||
blockers.append("job_already_running")
|
||||
if mode.charge and await billing.balance(s, ctx, "parse") < 1:
|
||||
blockers.append("quota_exhausted")
|
||||
states = await checklist.statuses(s, [project_id])
|
||||
return {
|
||||
"can_start": not blockers,
|
||||
"blockers": blockers,
|
||||
"billing": mode.mode,
|
||||
"charge": mode.charge,
|
||||
"charge_unit": "parse",
|
||||
"waive_until": mode.waive_until,
|
||||
"checklist_confirmed": states.get(project_id) == "confirmed",
|
||||
"pending_item_count": (await checklist.pending_counts(s, [project_id])).get(project_id, 0),
|
||||
"bid_documents": [await documents.doc_to_api(s, d) for d in bids],
|
||||
"lot_ids": await projects.bidding_lot_ids(s, project_id),
|
||||
}
|
||||
|
||||
|
||||
async def start(
|
||||
s: AsyncSession, ctx: TenantContext, project_id: UUID, document_ids: list[UUID], expected_charge: int
|
||||
) -> CheckRun:
|
||||
p = await projects.get(s, ctx, project_id)
|
||||
projects.ensure_editable(p)
|
||||
if running := await _running(s, project_id):
|
||||
raise AppError("job_already_running", job_id=str(running.id))
|
||||
if not await _tender_ready(s, project_id):
|
||||
raise AppError("no_parsed_tender")
|
||||
docs = (
|
||||
(await s.execute(select(SourceDocument).where(SourceDocument.id.in_(document_ids)))).scalars().all()
|
||||
if document_ids
|
||||
else []
|
||||
)
|
||||
if not docs or len(docs) != len(set(document_ids)):
|
||||
raise AppError("no_bid_documents")
|
||||
if any(d.project_id != project_id or d.role != "bid_volume" for d in docs):
|
||||
raise AppError("not_found")
|
||||
mode = await billing_mode(s, ctx, p)
|
||||
if expected_charge != mode.charge:
|
||||
raise AppError("billing_changed", preview=jsonable_encoder(await preview(s, ctx, project_id)))
|
||||
version, confirmed = await checklist_edit.check_snapshot(s, ctx, project_id)
|
||||
run_no = (
|
||||
await s.execute(
|
||||
select(func.coalesce(func.max(CheckRun.run_no), 0) + 1).where(CheckRun.project_id == project_id)
|
||||
)
|
||||
).scalar_one()
|
||||
run = CheckRun(
|
||||
id=uuid7(),
|
||||
tenant_id=ctx.tenant_id,
|
||||
project_id=project_id,
|
||||
run_no=run_no,
|
||||
checklist_version_id=version.id,
|
||||
checklist_confirmed=confirmed,
|
||||
rule_set_version=RULE_SET_VERSION,
|
||||
legal_regime=p.legal_regime,
|
||||
lot_ids=await projects.bidding_lot_ids(s, project_id),
|
||||
previous_run_id=p.latest_check_run_id,
|
||||
billing=mode.mode,
|
||||
created_by=ctx.user_id,
|
||||
)
|
||||
if mode.mode == "charged":
|
||||
run.quota_hold_id = await billing.hold(
|
||||
s, ctx, unit="parse", ref_type="check_run", ref_id=run.id, project_id=project_id
|
||||
)
|
||||
s.add(run)
|
||||
await s.flush()
|
||||
for d in docs:
|
||||
s.add(CheckRunDocument(tenant_id=ctx.tenant_id, check_run_id=run.id, document_id=d.id))
|
||||
if mode.mode == "waived_recheck":
|
||||
await billing.waive(
|
||||
s, ctx, unit="parse", ref_type="check_run", ref_id=run.id, project_id=project_id, reason="recheck_24h"
|
||||
)
|
||||
run.queue_job_id = await queue.enqueue(
|
||||
s, CHECK_TASK, {"tenant_id": str(ctx.tenant_id), "run_id": str(run.id)}, lock=f"project:{project_id}"
|
||||
)
|
||||
await audit.record(
|
||||
s,
|
||||
ctx,
|
||||
"check_run.start",
|
||||
"check_run",
|
||||
run.id,
|
||||
project_id=project_id,
|
||||
after={"run_no": run_no, "billing": mode.mode, "documents": [str(d.id) for d in docs]},
|
||||
)
|
||||
await s.flush()
|
||||
await s.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
async def get_run(s: AsyncSession, run_id: UUID) -> CheckRun:
|
||||
run = (await s.execute(select(CheckRun).where(CheckRun.id == run_id))).scalar_one_or_none()
|
||||
if run is None:
|
||||
raise AppError("not_found")
|
||||
return run
|
||||
|
||||
|
||||
async def request_cancel(s: AsyncSession, run_id: UUID) -> CheckRun:
|
||||
run = await get_run(s, run_id)
|
||||
if run.status not in ("queued", "running"):
|
||||
raise AppError("job_finished")
|
||||
run.cancel_requested_at = datetime.now(UTC)
|
||||
await s.flush()
|
||||
return run
|
||||
|
||||
|
||||
async def list_runs(s: AsyncSession, project_id: UUID) -> list[CheckRun]:
|
||||
rows = await s.execute(select(CheckRun).where(CheckRun.project_id == project_id).order_by(CheckRun.run_no.desc()))
|
||||
return list(rows.scalars())
|
||||
|
||||
|
||||
async def run_to_api(s: AsyncSession, ctx: TenantContext, run: CheckRun) -> dict[str, Any]:
|
||||
p = await projects.get(s, ctx, run.project_id)
|
||||
doc_ids = list(
|
||||
(await s.execute(select(CheckRunDocument.document_id).where(CheckRunDocument.check_run_id == run.id))).scalars()
|
||||
)
|
||||
docs = (await s.execute(select(SourceDocument).where(SourceDocument.id.in_(doc_ids)))).scalars().all()
|
||||
version_no = (await checklist_edit.version_numbers(s, [run.checklist_version_id])).get(run.checklist_version_id, 0)
|
||||
names = await identity.display_names(s, [run.created_by])
|
||||
error = None
|
||||
if run.status in ("failed", "cancelled") and run.error_code:
|
||||
error = {
|
||||
"code": run.error_code,
|
||||
"message": ERROR_MESSAGES.get(run.error_code, run.error_code),
|
||||
"refunded": run.billing == "charged",
|
||||
}
|
||||
return {
|
||||
"id": run.id,
|
||||
"run_no": run.run_no,
|
||||
"status": run.status,
|
||||
"stage": run.stage,
|
||||
"progress": run.progress,
|
||||
"cancel_requested": run.cancel_requested_at is not None,
|
||||
"error": error,
|
||||
"billing": run.billing,
|
||||
"is_latest": p.latest_check_run_id == run.id,
|
||||
"checklist_version_no": version_no,
|
||||
"checklist_confirmed": run.checklist_confirmed,
|
||||
"rule_set_version": run.rule_set_version,
|
||||
"legal_regime": run.legal_regime,
|
||||
"documents": [await documents.doc_to_api(s, d) for d in docs],
|
||||
"coverage": run.coverage,
|
||||
"counts": run.counts,
|
||||
"created_by": {"id": run.created_by, "name": names.get(run.created_by, "")},
|
||||
"created_at": run.created_at,
|
||||
"finished_at": run.finished_at,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 问题
|
||||
|
||||
|
||||
async def _items_of(s: AsyncSession, run: CheckRun) -> dict[UUID, dict[str, Any]]:
|
||||
return await checklist_edit.version_items(s, run.checklist_version_id)
|
||||
|
||||
|
||||
async def findings(
|
||||
s: AsyncSession, run_id: UUID, *, severity: str | None, status: str | None, presence: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
run = await get_run(s, run_id)
|
||||
q = select(Finding).where(Finding.check_run_id == run_id)
|
||||
if severity:
|
||||
q = q.where(Finding.severity == severity)
|
||||
if status:
|
||||
q = q.where(Finding.status == status)
|
||||
if presence:
|
||||
q = q.where(Finding.presence == presence)
|
||||
rows = list((await s.execute(q)).scalars())
|
||||
return await _to_api_many(s, run, rows)
|
||||
|
||||
|
||||
ORDER = {"fatal": 0, "important": 1, "hint": 2}
|
||||
|
||||
|
||||
async def _to_api_many(s: AsyncSession, run: CheckRun, rows: list[Finding]) -> list[dict[str, Any]]:
|
||||
"""按级别、再按招标原文顺序(清单项的排序键)。"""
|
||||
items = await _items_of(s, run)
|
||||
links = (
|
||||
await s.execute(
|
||||
select(FindingCitation)
|
||||
.where(FindingCitation.finding_id.in_([f.id for f in rows]))
|
||||
.order_by(FindingCitation.position)
|
||||
)
|
||||
).scalars()
|
||||
by_finding: dict[UUID, list[FindingCitation]] = {}
|
||||
for link in links:
|
||||
by_finding.setdefault(link.finding_id, []).append(link)
|
||||
cites = await citations.to_api_many(s, [link.citation_id for ls in by_finding.values() for link in ls])
|
||||
names = await identity.display_names(s, [f.resolved_by for f in rows if f.resolved_by])
|
||||
sort_keys = await checklist.sort_keys(s, [f.checklist_item_id for f in rows if f.checklist_item_id])
|
||||
|
||||
def api(f: Finding) -> dict[str, Any]:
|
||||
item = items.get(f.checklist_item_id) if f.checklist_item_id else None
|
||||
ls = by_finding.get(f.id, [])
|
||||
return {
|
||||
"id": f.id,
|
||||
"type": f.type,
|
||||
"severity": f.severity,
|
||||
"title": f.title,
|
||||
"explanation": f.explanation,
|
||||
"detector": f.detector,
|
||||
"checklist_item": {
|
||||
"id": f.checklist_item_id,
|
||||
"title": item.get("title"),
|
||||
"importance": item.get("importance"),
|
||||
"categories": item.get("categories"),
|
||||
}
|
||||
if item
|
||||
else None,
|
||||
"rule": {"code": f.rule_code, "description": ""} if f.rule_code and not item else None,
|
||||
"detail": f.detail,
|
||||
"tender_citations": [cites[x.citation_id] for x in ls if x.side == "tender" and x.citation_id in cites],
|
||||
"bid_citations": [cites[x.citation_id] for x in ls if x.side == "bid" and x.citation_id in cites],
|
||||
"search_scope": f.search_scope,
|
||||
"presence": f.presence,
|
||||
"carried_status": (f.detail or {}).get("carried_status"),
|
||||
"status": f.status,
|
||||
"resolution_note": f.resolution_note,
|
||||
"resolved_by": {"id": f.resolved_by, "name": names.get(f.resolved_by, "")} if f.resolved_by else None,
|
||||
"resolved_at": f.resolved_at,
|
||||
}
|
||||
|
||||
rows.sort(key=lambda f: (ORDER[f.severity], sort_keys.get(f.checklist_item_id or UUID(int=0), "~"), f.title))
|
||||
return [api(f) for f in rows]
|
||||
|
||||
|
||||
async def _get_finding(s: AsyncSession, finding_id: UUID) -> Finding:
|
||||
f = (await s.execute(select(Finding).where(Finding.id == finding_id))).scalar_one_or_none()
|
||||
if f is None:
|
||||
raise AppError("not_found")
|
||||
return f
|
||||
|
||||
|
||||
async def _editable_run(s: AsyncSession, ctx: TenantContext, run_id: UUID) -> tuple[CheckRun, Project]:
|
||||
run = await get_run(s, run_id)
|
||||
p = await projects.get(s, ctx, run.project_id)
|
||||
projects.ensure_editable(p)
|
||||
if p.latest_check_run_id != run.id:
|
||||
raise AppError("run_superseded")
|
||||
return run, p
|
||||
|
||||
|
||||
async def open_fatal(s: AsyncSession, run_id: UUID) -> int:
|
||||
return (
|
||||
await s.execute(
|
||||
select(func.count()).where(
|
||||
Finding.check_run_id == run_id,
|
||||
Finding.severity == "fatal",
|
||||
Finding.status == "pending",
|
||||
Finding.presence != "resolved",
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def _require_note(severity: str, status: str, note: str | None) -> None:
|
||||
if (status == "ignored" or (severity == "fatal" and status == "confirmed_ok")) and not (note or "").strip():
|
||||
raise AppError("note_required")
|
||||
|
||||
|
||||
async def _apply(s: AsyncSession, ctx: TenantContext, f: Finding, p: Project, status: str, note: str | None) -> None:
|
||||
before = {"status": f.status, "note": f.resolution_note}
|
||||
done = status != "pending"
|
||||
f.status = status
|
||||
f.resolution_note = ((note or "").strip() or None) if done else None
|
||||
f.resolved_by, f.resolved_at = (ctx.user_id, datetime.now(UTC)) if done else (None, None)
|
||||
if f.severity == "fatal" and status == "confirmed_ok":
|
||||
await feedback.record(
|
||||
s,
|
||||
ctx,
|
||||
project_id=p.id,
|
||||
object_type="finding",
|
||||
object_id=f.id,
|
||||
kind="false_positive",
|
||||
source="fatal_confirm",
|
||||
note=f.resolution_note,
|
||||
)
|
||||
await audit.record(
|
||||
s,
|
||||
ctx,
|
||||
"finding.resolve" if done else "finding.reopen",
|
||||
"finding",
|
||||
f.id,
|
||||
project_id=p.id,
|
||||
before=before,
|
||||
after={"status": f.status, "note": f.resolution_note},
|
||||
)
|
||||
|
||||
|
||||
async def resolve(
|
||||
s: AsyncSession, ctx: TenantContext, finding_id: UUID, status: str, note: str | None
|
||||
) -> dict[str, Any]:
|
||||
f = await _get_finding(s, finding_id)
|
||||
run, p = await _editable_run(s, ctx, f.check_run_id)
|
||||
if f.presence == "resolved":
|
||||
raise AppError("finding_resolved_this_run")
|
||||
_require_note(f.severity, status, note)
|
||||
await _apply(s, ctx, f, p, status, note)
|
||||
await s.flush()
|
||||
p.open_fatal_count = await open_fatal(s, run.id)
|
||||
await s.flush()
|
||||
(out,) = await _to_api_many(s, run, [f])
|
||||
return {"finding": out, "project": projects.readiness(p)}
|
||||
|
||||
|
||||
async def reopen(s: AsyncSession, ctx: TenantContext, finding_id: UUID) -> dict[str, Any]:
|
||||
f = await _get_finding(s, finding_id)
|
||||
run, p = await _editable_run(s, ctx, f.check_run_id)
|
||||
if f.presence == "resolved":
|
||||
raise AppError("finding_resolved_this_run")
|
||||
await _apply(s, ctx, f, p, "pending", None)
|
||||
await s.flush()
|
||||
p.open_fatal_count = await open_fatal(s, run.id)
|
||||
await s.flush()
|
||||
(out,) = await _to_api_many(s, run, [f])
|
||||
return {"finding": out, "project": projects.readiness(p)}
|
||||
|
||||
|
||||
async def bulk_resolve(
|
||||
s: AsyncSession, ctx: TenantContext, run_id: UUID, finding_ids: list[UUID], status: str, note: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""只限「提示」级(FR-M3-07);混进了别的级别就整批拒绝,返回不符合的问题编号。"""
|
||||
run, p = await _editable_run(s, ctx, run_id)
|
||||
rows = list((await s.execute(select(Finding).where(Finding.id.in_(finding_ids)))).scalars())
|
||||
if len(rows) != len(set(finding_ids)) or any(f.check_run_id != run.id for f in rows):
|
||||
raise AppError("not_found")
|
||||
bad = [str(f.id) for f in rows if f.severity != "hint" or f.presence == "resolved"]
|
||||
if bad:
|
||||
raise AppError("bulk_only_hint", finding_ids=bad)
|
||||
_require_note("hint", status, note)
|
||||
for f in rows:
|
||||
await _apply(s, ctx, f, p, status, note)
|
||||
await s.flush()
|
||||
p.open_fatal_count = await open_fatal(s, run.id)
|
||||
await s.flush()
|
||||
return {"items": await _to_api_many(s, run, rows), "project": projects.readiness(p)}
|
||||
|
||||
|
||||
async def item_results(s: AsyncSession, run_id: UUID) -> list[dict[str, Any]]:
|
||||
"""每条清单项的检查结论(C9):「已响应」也有出处,可以复核、可以反馈判断有误。"""
|
||||
run = await get_run(s, run_id)
|
||||
items = await _items_of(s, run)
|
||||
rows = list((await s.execute(select(CheckItemResult).where(CheckItemResult.check_run_id == run_id))).scalars())
|
||||
cites = await citations.to_api_many(s, [c for r in rows for c in r.bid_citation_ids])
|
||||
return [
|
||||
{
|
||||
"checklist_item_id": r.checklist_item_id,
|
||||
"title": (items.get(r.checklist_item_id) or {}).get("title"),
|
||||
"verdict": r.verdict,
|
||||
"layer": r.layer,
|
||||
"evidence_status": r.evidence_status,
|
||||
"reason_code": r.reason_code,
|
||||
"bid_citations": [cites[c] for c in r.bid_citation_ids if c in cites],
|
||||
"detail": r.detail,
|
||||
"reused": r.reused_from_run_id is not None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
Reference in New Issue
Block a user