diff --git a/admin/src/api/tcm.ts b/admin/src/api/tcm.ts index e62b0277b..67c836b24 100644 --- a/admin/src/api/tcm.ts +++ b/admin/src/api/tcm.ts @@ -467,7 +467,7 @@ export function prescriptionOrderEditTime(params: { id: number; create_time: str return request.post({ url: '/tcm.prescriptionOrder/editTime', params }) } -/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */ +/** 仅修改业务订单的承运商与快递单号;处方和支付单均审核通过后,所有履约状态均可使用 */ export function prescriptionOrderDdcode(params: { id: number express_company: string diff --git a/admin/src/views/consumer/prescription/order_list.vue b/admin/src/views/consumer/prescription/order_list.vue index 1737e2d01..227818ee1 100644 --- a/admin/src/views/consumer/prescription/order_list.vue +++ b/admin/src/views/consumer/prescription/order_list.vue @@ -871,8 +871,17 @@ - - + + +
+ 编辑订单后需重新审核;双审通过后,请通过列表的单号操作填写或修改 +
@@ -1067,8 +1076,17 @@ - - + + +
+ 编辑订单后需重新审核;双审通过后,请通过列表的单号操作填写或修改 +
@@ -2975,9 +2993,13 @@ function canWithdrawRow(row: { fulfillment_status?: number }) { return Number(row.fulfillment_status) === 1 } -function canShipRow(row: { fulfillment_status?: number }) { - // 履约中(2) 可发货 - return Number(row.fulfillment_status) === 2 +function canShipRow(row: { + fulfillment_status?: number + prescription_audit_status?: number + payment_slip_audit_status?: number +}) { + // 履约中(2) 且处方、支付单均审核通过才可发货 + return Number(row.fulfillment_status) === 2 && isDualAuditPassed(row) } type ShipMode = 'gancao' | 'direct' @@ -3073,8 +3095,11 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1 } -function canQuickTrackRow(_row: { fulfillment_status?: number }) { - return true +function canQuickTrackRow(row: { + prescription_audit_status?: number + payment_slip_audit_status?: number +}) { + return isDualAuditPassed(row) } function canUploadPharmacyRow(row: { @@ -3270,7 +3295,7 @@ const editSaving = ref(false) /** 编辑订单分步:0 收货 / 1 服务与支付单 / 2 金额与确认(与处方列表「创建业务订单」一致) */ const editOrderStep = ref(0) /** 顶部「关联处方」卡片数据(来自业务订单详情中的 prescription) */ -const editOrderPrescription = ref | null>(null) +const editOrderPrescription = ref | null>(null) /** 甘草 SCM 已提交:弹窗仅展示并提交快递单号与承运商 */ const editGancaoLogisticsOnlyMode = ref(false) /** 用于提示文案展示甘草处方单号 */ @@ -3455,7 +3480,7 @@ const editRules = computed(() => { return rules }) -function resetEditOrderDialog() { +function resetEditOrderDialog() { editOrderStep.value = 0 editOrderPrescription.value = null editGancaoLogisticsOnlyMode.value = false @@ -3507,8 +3532,8 @@ async function openEdit(row: { } editOrderStep.value = 0 editOrderPrescription.value = null - editVisible.value = true - editDialogLoading.value = true + editVisible.value = true + editDialogLoading.value = true try { const res: any = await prescriptionOrderDetail({ id: row.id }) const d = res?.data ?? res @@ -3531,7 +3556,7 @@ async function openEdit(row: { editOrderPrescription.value = null } - editForm.id = d.id + editForm.id = d.id editForm.prescription_id = Number(d.prescription_id) || 0 editForm.diagnosis_id = Number(d.diagnosis_id) || 0 editForm.assistant_id = Number(d.assistant_id) || 0 @@ -3627,7 +3652,6 @@ async function submitEdit() { service_channel: editForm.service_channel || '', service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '', express_company: editForm.express_company || 'auto', - tracking_number: editForm.tracking_number || '', fee_type: editForm.fee_type, amount: editForm.amount, remark_extra: editForm.remark_extra || '', @@ -3915,7 +3939,17 @@ const quickTrackForm = reactive({ tracking_number: '' }) -function openQuickTrack(row: { id: number; express_company?: unknown; tracking_number?: unknown }) { +function openQuickTrack(row: { + id: number + express_company?: unknown + tracking_number?: unknown + prescription_audit_status?: number + payment_slip_audit_status?: number +}) { + if (!canQuickTrackRow(row)) { + feedback.msgWarning('处方审核和支付单审核均通过后,才可填写或修改快递单号') + return + } quickTrackRowId.value = row.id quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto' quickTrackForm.tracking_number = String(row.tracking_number || '') @@ -3963,7 +3997,19 @@ function resolveShipModeForRow(row: { id: number; ship_mode?: unknown }) { return normalizeShipMode(row.ship_mode) } -function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) { +function openShip(row: { + id: number + express_company?: unknown + tracking_number?: unknown + ship_mode?: unknown + fulfillment_status?: number + prescription_audit_status?: number + payment_slip_audit_status?: number +}) { + if (!canShipRow(row)) { + feedback.msgWarning('仅处方审核和支付单审核均通过的履约中订单可填写单号并发货') + return + } shipRowId.value = Number(row.id) shipForm.ship_mode = resolveShipModeForRow(row) shipForm.express_company = String(row.express_company || 'auto') || 'auto' diff --git a/admin/src/views/consumer/prescription/order_list_h5.vue b/admin/src/views/consumer/prescription/order_list_h5.vue index 1d4001281..d55236421 100644 --- a/admin/src/views/consumer/prescription/order_list_h5.vue +++ b/admin/src/views/consumer/prescription/order_list_h5.vue @@ -1624,8 +1624,17 @@ - - + + +
+ 编辑订单后需重新审核;双审通过后,请通过列表的单号操作填写或修改 +
@@ -3422,9 +3431,13 @@ function canWithdrawRow(row: { fulfillment_status?: number }) { return Number(row.fulfillment_status) === 1 } -function canShipRow(row: { fulfillment_status?: number }) { - // 履约中(2) 可发货 - return Number(row.fulfillment_status) === 2 +function canShipRow(row: { + fulfillment_status?: number + prescription_audit_status?: number + payment_slip_audit_status?: number +}) { + // 履约中(2) 且处方、支付单均审核通过才可发货 + return Number(row.fulfillment_status) === 2 && isDualAuditPassed(row) } function canAddPayOrderRow(row: { fulfillment_status?: number }) { @@ -3444,8 +3457,11 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1 } -function canQuickTrackRow(_row: { fulfillment_status?: number }) { - return true +function canQuickTrackRow(row: { + prescription_audit_status?: number + payment_slip_audit_status?: number +}) { + return isDualAuditPassed(row) } function canUploadPharmacyRow(row: { @@ -4181,7 +4197,7 @@ const editSaving = ref(false) /** 编辑订单分步:0 收货 / 1 服务与支付单 / 2 金额与确认(与处方列表「创建业务订单」一致) */ const editOrderStep = ref(0) /** 顶部「关联处方」卡片数据(来自业务订单详情中的 prescription) */ -const editOrderPrescription = ref | null>(null) +const editOrderPrescription = ref | null>(null) const editOrderStepLead = computed(() => { const texts = [ @@ -4381,7 +4397,7 @@ const editRules = computed(() => { return rules }) -function resetEditOrderDialog() { +function resetEditOrderDialog() { editOrderStep.value = 0 editOrderPrescription.value = null editFormRef.value?.clearValidate() @@ -4431,8 +4447,8 @@ async function openEdit(row: { } editOrderStep.value = 0 editOrderPrescription.value = null - editVisible.value = true - editDialogLoading.value = true + editVisible.value = true + editDialogLoading.value = true try { const res: any = await prescriptionOrderDetail({ id: row.id }) const d = res?.data ?? res @@ -4455,7 +4471,7 @@ async function openEdit(row: { editOrderPrescription.value = null } - editForm.id = d.id + editForm.id = d.id editForm.prescription_id = Number(d.prescription_id) || 0 editForm.diagnosis_id = Number(d.diagnosis_id) || 0 editForm.assistant_id = Number(d.assistant_id) || 0 @@ -4540,7 +4556,6 @@ async function submitEdit() { service_channel: editForm.service_channel || '', service_package: Array.isArray(editForm.service_package) ? editForm.service_package.join(',') : '', express_company: editForm.express_company || 'auto', - tracking_number: editForm.tracking_number || '', fee_type: editForm.fee_type, amount: editForm.amount, remark_extra: editForm.remark_extra || '', @@ -4788,7 +4803,17 @@ const quickTrackForm = reactive({ tracking_number: '' }) -function openQuickTrack(row: { id: number; express_company?: unknown; tracking_number?: unknown }) { +function openQuickTrack(row: { + id: number + express_company?: unknown + tracking_number?: unknown + prescription_audit_status?: number + payment_slip_audit_status?: number +}) { + if (!canQuickTrackRow(row)) { + feedback.msgWarning('处方审核和支付单审核均通过后,才可填写或修改快递单号') + return + } quickTrackRowId.value = row.id quickTrackForm.express_company = String(row.express_company || 'auto') || 'auto' quickTrackForm.tracking_number = String(row.tracking_number || '') @@ -4840,7 +4865,19 @@ const shipDialogModeDisplay = computed(() => shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发' ) -function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) { +function openShip(row: { + id: number + express_company?: unknown + tracking_number?: unknown + ship_mode?: unknown + fulfillment_status?: number + prescription_audit_status?: number + payment_slip_audit_status?: number +}) { + if (!canShipRow(row)) { + feedback.msgWarning('仅处方审核和支付单审核均通过的履约中订单可填写单号并发货') + return + } shipRowId.value = row.id shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao' shipForm.express_company = String(row.express_company || 'auto') || 'auto' diff --git a/admin/tests/prescription-order-tracking.test.cjs b/admin/tests/prescription-order-tracking.test.cjs new file mode 100644 index 000000000..50b133aa8 --- /dev/null +++ b/admin/tests/prescription-order-tracking.test.cjs @@ -0,0 +1,156 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const test = require('node:test') +const ts = require('typescript') +const vue = require('vue') +const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc') + +const handlers = [ + 'isDualAuditPassed', 'canQuickTrackRow', 'canShipRow', 'openQuickTrack', + 'submitQuickTrack', 'openShip', 'openEdit', 'submitEdit', 'resetEditOrderDialog' +] +const stateNames = [ + 'quickTrackVisible', 'quickTrackSaving', 'quickTrackRowId', 'quickTrackForm', + 'shipVisible', 'shipRowId', 'shipForm', 'editVisible', 'editDialogLoading', + 'editSaving', 'editOrderStep', 'editOrderPrescription', + 'editFormRef', 'editForm', 'editDepositMin' +] + +for (const page of ['order_list.vue', 'order_list_h5.vue']) { + const filename = path.join(__dirname, '../src/views/consumer/prescription', page) + const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename }) + assert.deepEqual(errors, []) + const script = compileScript(descriptor, { id: page }) + const ast = ts.createSourceFile(filename + '.ts', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true) + // Execute the actual order handlers with only network and unrelated UI dependencies stubbed. + const declarations = new Map() + for (const node of ast.statements) { + if (ts.isFunctionDeclaration(node) && node.name) declarations.set(node.name.text, node.getText(ast)) + if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + declarations.set(declaration.name.getText(ast), `const ${declaration.getText(ast)}`) + } + } + } + const selected = [...handlers, ...stateNames] + for (const name of selected) assert.ok(declarations.has(name), `${page}: missing ${name}`) + const compiled = ts.transpileModule(selected.map(name => declarations.get(name)).join('\n'), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText + + function instance(detail = {}, saveTracking = async () => {}) { + const warnings = [] + const errors = [] + const edits = [] + const tracks = [] + const globals = { + ref: vue.ref, reactive: vue.reactive, nextTick: vue.nextTick, + feedback: { msgWarning: message => warnings.push(message), msgError: message => errors.push(message), msgSuccess() {} }, + prescriptionOrderDetail: async () => ({ data: detail }), + prescriptionOrderEdit: async payload => edits.push(payload), + prescriptionOrderDdcode: async payload => { tracks.push(payload); await saveTracking(payload) }, + canEditRow: () => true, + parseServicePackageValues: () => [], + loadEditPaidOrders: async () => {}, + resolveShipModeForRow: row => row.ship_mode || 'gancao', + editGancaoLogisticsOnlyMode: vue.ref(false), + editGancaoDisplayNo: vue.ref(''), + detailDrawerRef: vue.ref(null), + detailVisible: vue.ref(false), + getLists() {} + } + const state = new Function(...Object.keys(globals), `${compiled}\nreturn { ${selected.join(', ')} }`)(...Object.values(globals)) + state.editFormRef.value = { validate: async () => {}, clearValidate() {} } + return { state, warnings, errors, edits, tracks } + } + + test(`${page}: only two approved audits unlock tracking, including legacy string statuses`, () => { + const { state } = instance() + for (const rx of [undefined, null, 0, 1, 2, '0', '1', '2']) { + for (const pay of [undefined, null, 0, 1, 2, '0', '1', '2']) { + const row = { prescription_audit_status: rx, payment_slip_audit_status: pay, fulfillment_status: 2 } + const allowed = [1, '1'].includes(rx) && [1, '1'].includes(pay) + assert.equal(state.canQuickTrackRow(row), allowed, `tracking: ${rx}/${pay}`) + assert.equal(state.canShipRow(row), allowed, `shipping: ${rx}/${pay}`) + } + } + for (const status of [1, 3, 4, 5, 6, 9]) { + assert.equal(state.canShipRow({ prescription_audit_status: 1, payment_slip_audit_status: 1, fulfillment_status: status }), false) + } + }) + + test(`${page}: direct handler calls cannot open tracking or shipping before both approvals`, () => { + for (const [rx, pay] of [[0, 0], [1, 0], [0, 1], [2, 1], [1, 2]]) { + const { state, warnings } = instance() + const row = { id: 42, prescription_audit_status: rx, payment_slip_audit_status: pay, fulfillment_status: 2 } + state.openQuickTrack(row) + state.openShip(row) + assert.equal(state.quickTrackVisible.value, false) + assert.equal(state.shipVisible.value, false) + assert.equal(state.quickTrackRowId.value, 0) + assert.equal(state.shipRowId.value, 0) + assert.equal(warnings.length, 2) + } + }) + + test(`${page}: approved orders can fill or replace the number and preserve the carrier`, async () => { + for (const oldNumber of ['', 'SF-OLD']) { + const { state, tracks } = instance() + const row = { id: 42, prescription_audit_status: 1, payment_slip_audit_status: 1, fulfillment_status: 2, express_company: 'sf', tracking_number: oldNumber } + state.openQuickTrack(row) + assert.equal(state.quickTrackVisible.value, true) + assert.equal(state.quickTrackForm.tracking_number, oldNumber) + state.quickTrackForm.tracking_number = ' SF-NEW ' + await state.submitQuickTrack() + assert.deepEqual(tracks, [{ id: 42, express_company: 'sf', tracking_number: 'SF-NEW' }]) + assert.equal(state.quickTrackVisible.value, false) + state.openShip(row) + assert.equal(state.shipVisible.value, true) + } + }) + + test(`${page}: a server rejection after audit revocation retains the dialog and entered number`, async () => { + const { state } = instance({}, async () => { throw new Error('audit revoked') }) + state.openQuickTrack({ id: 42, prescription_audit_status: 1, payment_slip_audit_status: 1 }) + state.quickTrackForm.tracking_number = 'SF-NEW' + await state.submitQuickTrack() + assert.equal(state.quickTrackVisible.value, true) + assert.equal(state.quickTrackSaving.value, false) + assert.equal(state.quickTrackForm.tracking_number, 'SF-NEW') + }) + + test(`${page}: editing preserves the existing number while saving other fields before approval`, async () => { + const { state, edits, errors } = instance({ id: 42, prescription_audit_status: 1, payment_slip_audit_status: 0, tracking_number: 'SF-EXISTING' }) + await state.openEdit({ id: 42, prescription_audit_status: 1, payment_slip_audit_status: 1 }) + assert.deepEqual(errors, []) + assert.equal(state.editForm.tracking_number, 'SF-EXISTING') + state.editForm.tracking_number = 'FORGED' + state.editForm.recipient_name = '修改后的收货人' + state.editOrderStep.value = 2 + await state.submitEdit() + assert.equal(edits.length, 1) + assert.equal(edits[0].recipient_name, '修改后的收货人') + assert.equal(Object.hasOwn(edits[0], 'tracking_number'), false) + }) + + test(`${page}: ordinary editing cannot change tracking while resetting an approved payment audit`, async () => { + const { state, edits, errors } = instance({ id: 42, prescription_audit_status: '1', payment_slip_audit_status: '1' }) + await state.openEdit({ id: 42 }) + assert.deepEqual(errors, []) + state.editForm.tracking_number = 'SF-NEW' + state.editOrderStep.value = 2 + await state.submitEdit() + assert.equal(edits.length, 1) + assert.equal(Object.hasOwn(edits[0], 'tracking_number'), false) + }) + + test(`${page}: template compiles with tracking controls bound to audit restrictions`, () => { + const template = compileTemplate({ source: descriptor.template.content, filename, id: page, compilerOptions: { bindingMetadata: script.bindings } }) + assert.deepEqual(template.errors, []) + const inputs = [...descriptor.template.content.matchAll(/]*v-model="editForm\.tracking_number"[^>]*>/g)] + assert.equal(inputs.length, page === 'order_list.vue' ? 2 : 1) + for (const [input] of inputs) assert.match(input, /\sdisabled(?:\s|\/?>)/) + assert.match(descriptor.template.content, /v-if="canQuickTrackRow\(row\)"/) + }) +} diff --git a/app/research/issued-prescription-ai-ui-redesign.md b/app/research/issued-prescription-ai-ui-redesign.md new file mode 100644 index 000000000..80c4a7639 --- /dev/null +++ b/app/research/issued-prescription-ai-ui-redesign.md @@ -0,0 +1,26 @@ +# 已开处方 → AI 界面改造 + +## 功能分析 + +这是基于已保存资料快照的双模型分析与医生复核工作台。主流程是确认患者和批次、查看模型完成情况、对照原方差异、核查资料缺口、记录所选模型的复核意见。 + +保留六个入口:对比总览、完整报告、候选与逐味、资料与缺口、处理进度、历史与趋势。历史批次切换、刷新、重新分析、单模型重试、独立复核草稿及保存继续使用现有服务和权限逻辑。医生原方及支持报告仍可访问。 + +## 设计决定 + +- 主程序 `shell.py` 将 prescriptions 等业务页列为科技蓝页面。因此继续复用 `reception_style.TECH_BLUE`:主色 #1769E8,背景 #F3F7FD,白色面板,分割线 #DBE5F2。没有采用主程序其他页面的靛蓝令牌,也没有修改全局主题。 +- 深蓝渐变横幅改为白色标题工具栏。批次状态放在顶行,患者及原方信息单独成行,当前导航用浅蓝背景与蓝色底线标识。 +- 缩小一致度圆环和数字,保留共同药味、候选药味与药味重合的计算依据;修复指标区样式误把分隔线应用到每个子标签的问题。 +- 总览由三栏改成剂量差异主区与固定复核侧栏。三方交集和附件覆盖改为可展开的摘要,减少初始屏幕空白和对主图的挤压。 +- 复核模型和复核状态并排显示,意见输入和保存按钮放在一起。顶部保存动作显示当前模型名,切换时同步更新。 +- 短窗口收起次要信息,保留剂量主图和复核输入;展开图表时剂量滚动区域可让出高度。列表内容保持最小高度,避免刷新后条目重叠。 +- 保留未知值与零值区分、不可比/历史状态处理、模型各自失败重试以及现有一致度说明。 + +## 验证 + +- 处方 AI 五组回归测试覆盖数据处理、权限、异步读取、逐味比较、页面、模型复核与布局。 +- 新增八种窗口/展开状态组合的控件边界检查,以及展开/收起不改变比较数据和复核清单的检查。 +- 离线模拟数据预览覆盖 1440×940、1280×860、1024×700、940×640,各内容页面、展开图表、运行中、失败、历史及统计窗口。 +- 预览输出:`app/artifacts/issued_prescription_redesign/`。模拟数据仅供 UI 验证,不对应真实患者。 + +本次改动在现有未提交工作区基础上完成,仅调整桌面呈现与相关回归检查;没有重新打包或发布桌面安装程序。 diff --git a/app/scripts/render_issued_prescription_ai.py b/app/scripts/render_issued_prescription_ai.py new file mode 100644 index 000000000..6d144879b --- /dev/null +++ b/app/scripts/render_issued_prescription_ai.py @@ -0,0 +1,281 @@ +"""Render the prescription comparison window with synthetic, offline data.""" + +from __future__ import annotations + +import os +from copy import deepcopy +from pathlib import Path +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication + +from doctor_workstation.ui.dialogs import issued_prescription_ai as ai +from doctor_workstation.ui.theme import apply_theme + + +def example_batch() -> dict[str, Any]: + """Three prescriptions that actually differ, so every state of the page has something to draw.""" + + doctor = {"生地黄": 16, "天花粉": 15, "干石斛": 20, "醋五味子": 6, + "生麦冬": 12, "茯苓": 10, "红参片": 6, "生牡丹皮": 10} + candidates = { + "qwen": {"生地黄": 15, "干石斛": 12, "醋五味子": 6, "生麦冬": 12, "茯苓": 15, "麸炒白术": 12, "丹参": 15}, + "openai": {"生地黄": 12, "醋五味子": 6, "生麦冬": 10, "茯苓": 12, "生白芍": 5, "炒酸枣仁": 6}, + } + reports = { + "qwen": {"summary": "界面演示数据:对照药味组成、剂量及用法,辅助医生逐项复核。", + "diagnosis": "消渴病,气阴两虚兼血热。兼证需结合舌脉资料核实。", + "analysis": "阅读药方对比页,查看同名药材的剂量差异、仅医生方药味与仅候选方药味。", + "risk_assessment": [{"level": "high", "label": "血压数据缺失,影响补气药安全性评估"}, + {"level": "medium", "label": "肝肾功能具体指标未提供"}], + "missing_information": ["甲状腺功能及眼底检查报告缺失", "舌象与脉诊仅有附件,无文本记录"]}, + "openai": {"summary": "界面演示数据:两份候选方独立生成,引用编号可回查原始资料。", + "diagnosis": "已记录气阴两虚证;辨证依据需补充。", + "analysis": "候选方以益气养阴为主,安神药味为本模型新增,需要医师确认。", + "risk_assessment": [{"level": "high", "label": "缺少当前用药记录,无法排除配伍风险"}], + "missing_information": ["舌象与脉诊仅有附件,无文本记录", "近期体重变化及 BMI 数据缺失"]}, + } + models = {} + for key, doses in candidates.items(): + herbs, rows = [], [] + for name in sorted(set(doctor) | set(doses), key=lambda item: (item not in doses, item)): + common = {"name": name, "unit": "g", "dose_basis": "per_dose", "formula_type": "主方"} + left, right = doctor.get(name), doses.get(name) + if right is not None: + herbs.append({**common, "dosage": right}) + rows.append({ + **common, "key": name, + "doctor": {**common, "dosage": left} if left is not None else None, + "candidate": {**common, "dosage": right, "source_rows": [len(herbs) - 1]} if right is not None else None, + "doctor_dosage": left, "candidate_dosage": right, + "match_type": "candidate_only" if left is None else "doctor_only" if right is None else "matched", + "contribution": min(left, right) / max(left, right) if left and right else None, + }) + matched = sum(row["match_type"] == "matched" for row in rows) + denominator = len(doctor) + len(herbs) + models[key] = { + "status": "success", "report_id": 10 if key == "qwen" else 11, + "candidate": {"status": "available_for_review", "prescription_name": "候选药方 · 界面示例", + "herbs": herbs, "dose_basis": "per_dose", "prescription_type": "浓缩水丸", + "usage_instruction": "服法由医生复核后确认。", "usage_days": 7, "times_per_day": 2, + "rationale": "这是用于检查界面排版的模拟药方,不对应真实患者。", + "risk_warnings": ["药味与剂量差异需要逐项复核。"]}, + "comparison": {"status": "comparable", + "score": 200 * sum(row["contribution"] or 0 for row in rows) / denominator, + "herb_score": 200 * matched / denominator, "matched_count": matched, + "doctor_count": len(doctor), "candidate_count": len(herbs), "rows": rows, + "reason": "药味与剂量可比;服法与疗程需单独复核。", "usage_differences": []}, + "report": reports[key], + "coverage": {"status": "incomplete", "complete": False, + "files": [{"file_id": f"a{index:04d}", "type": "image" if index % 3 else "document", + "status": "processed", "transmitted": True, "version_verified": True} + for index in range(20 if key == "qwen" else 17)] + + [{"file_id": f"z{index:04d}", "type": "document", "status": "restricted", + "transmitted": False, "version_verified": False, + "reason": "FILE_UNAVAILABLE_OR_UNSUPPORTED"} for index in range(2)]}, + "progress": {"stage_label": "处理完成", "elapsed_seconds": 135 if key == "qwen" else 591, + "attempt": 1}, + "usage": {"total_calls": 3, "calls": [ + {"stage": "text:0", "ok": True, "latency_ms": 3120, "file_count": 0, + "usage": {"completion_tokens": 980}, "error_code": ""}, + {"stage": "files:0", "ok": True, "latency_ms": 22400, + "file_count": 20 if key == "qwen" else 17, + "usage": {"completion_tokens": 2140}, "error_code": ""}, + {"stage": "final", "ok": True, "latency_ms": 18800, "file_count": 0, + "usage": {"completion_tokens": 5617}, "error_code": ""}]}, + "review": {"status": "viewed", "comment": ""}, + "algorithm_version": "prescription-soft-dice-v1.1.0", + "prompt_version": "manual-prescription-required-candidate-v4", + } + return {"id": 40, "prescription_id": 7556, "patient_id": 1391, "diagnosis_id": 1391, + "prescription_revision": 1, "status": "success", "validity": "current", + "comparison_type": "non_independent", "models": models, "coverage_status": "partial", + "created_at": "2026-09-10 15:29:00", "cutoff_at": "2026-09-10 15:29:00", + "source_summary": {"diagnoses_count": 2, "attachment_count": 22, "video_calls_count": 4, + "chat_messages_count": 137, "source_record_count": 31}, + "doctor_snapshot": {"patient": {"name": "张卫君", "gender": 2, "gender_label": "女", "age": 58}, + "diagnosis": {"clinical_diagnosis": "2型糖尿病 消渴病 · 气阴两虚兼血热", + "chief_complaint": "咳嗽反复1月余"}, + "prescription": {"herbs": [{"name": name, "dosage": dose, "unit": "g", + "dose_basis": "per_dose", "formula_type": "主方"} + for name, dose in doctor.items()], + "prescription_type": "浓缩水丸", "dose_count": 1, + "usage_instruction": "每日1剂,水煎分服。"}}, + "missing": [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True}, + {"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True}, + {"code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE", "critical": False}]} + + +def example_statistics() -> dict[str, Any]: + """Doctor-level shape the statistics window renders; synthetic, but structurally complete.""" + + def doctor(identifier: int, name: str, totals: tuple[int, int, int], + qwen: tuple[int, float | None], openai: tuple[int, float | None], + review: tuple[int, int]) -> dict[str, Any]: + total, patients, paired = totals + models = {} + for key, (eligible, mean) in (("qwen", qwen), ("openai", openai)): + share = (0.34, 0.38, 0.18, 0.07, 0.03) + models[key] = {"eligible_count": eligible, "mean": mean, + "median": None if mean is None else round(mean - 1.4, 1), + "excluded_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": max(0, total - eligible - 2), + "incomplete_coverage": min(2, max(0, total - eligible))}, + "distribution": {name: round(eligible * fraction) + for name, fraction in zip( + ("[0,20)", "[20,40)", "[40,60)", "[60,80)", "[80,100]"), + share, strict=True)}} + evaluated, qualified = review + return {"doctor_id": identifier, "doctor_name": name, "total_count": total, + "patient_count": patients, "paired_count": paired, "models": models, + "review": {"evaluated_count": evaluated, "qualified_count": qualified, + "qualified_rate": None if not evaluated else round(100 * qualified / evaluated, 1)}} + + doctors = [doctor(26, "何医生", (18, 14, 9), (12, 21.4), (9, 18.9), (6, 4)), + doctor(31, "李医生", (11, 9, 4), (7, 26.8), (5, 24.1), (3, 1)), + doctor(44, "王医生", (6, 5, 1), (2, 15.2), (0, None), (0, 0))] + return {"total_count": sum(item["total_count"] for item in doctors), + "patient_count": sum(item["patient_count"] for item in doctors), "doctors": doctors} + + +class PreviewRepository: + def __init__(self) -> None: + self.batch = example_batch() + + def list_prescription_ai_reports(self, **_params: Any) -> dict[str, Any]: + return {"enabled": True, "lists": deepcopy(self.history()), "count": len(self.history())} + + def history(self) -> list[dict[str, Any]]: + """The current batch plus the earlier ones it supersedes, newest first.""" + + older = [ + {"id": 39, "created_at": "2026-09-10 15:18:00", "status": "success", "validity": "superseded", + "comparison_type": "non_independent", + "models": {"qwen": {"comparison": {"status": "comparable", "score": 48.9}, + "algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}, + "openai": {"comparison": {"status": "comparable", "score": 41.7}, + "algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}}}, + {"id": 38, "created_at": "2026-09-10 14:05:00", "status": "success", "validity": "superseded", + "models": {"qwen": {"comparison": {"status": "comparable", "score": 33.4}, + "algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}, + "openai": {"comparison": {"status": "comparable", "score": 31.1}, + "algorithm_version": "prescription-soft-dice-v1.0.1", "prompt_version": "v3"}}}, + {"id": 37, "created_at": "2026-09-10 13:25:00", "status": "failed", "validity": "superseded", + "models": {"qwen": {"error_message": "模型返回未通过校验", + "algorithm_version": "prescription-soft-dice-v1.0.1"}, + "openai": {"comparison": {"status": "not_comparable"}}}}, + {"id": 36, "created_at": "2026-09-10 12:34:00", "status": "success", "validity": "superseded", + "models": {"qwen": {"comparison": {"status": "not_comparable"}, + "algorithm_version": "prescription-soft-dice-v1.0.0"}, + "openai": {"comparison": {"status": "not_comparable"}}}}, + ] + return [self.batch, *older] + + def get_prescription_ai_report(self, _batch_id: int) -> dict[str, Any]: + return deepcopy(self.batch) + + def prescription_ai_statistics(self, *_args: Any, **_params: Any) -> dict[str, Any]: + return deepcopy(example_statistics()) + + +def main() -> None: + application = QApplication.instance() or QApplication([]) + apply_theme(application) + output = Path(__file__).resolve().parents[1] / "artifacts" / "issued_prescription_redesign" + output.mkdir(parents=True, exist_ok=True) + + def immediate(function: Any, **callbacks: Any) -> None: + result = function() + if callbacks.get("on_success"): + callbacks["on_success"](result) + if callbacks.get("on_finished"): + callbacks["on_finished"]() + + ai.run_async = immediate + repository = PreviewRepository() + dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=7556, + current_user={"name": "张医生"}) + dialog.show() + names = {dialog.tabs.tabText(index): index for index in range(dialog.tabs.count())} + for filename, width, height, tab in ( + ("prescription-1440.png", 1440, 940, "对比总览"), + ("prescription-1280.png", 1280, 860, "对比总览"), + ("prescription-1024.png", 1024, 700, "对比总览"), + ("prescription-940.png", 940, 640, "对比总览"), + ("original-1280.png", 1280, 860, "原方记录"), + ("analysis-1280.png", 1280, 860, "完整报告"), + ("candidate-1280.png", 1280, 860, "方义与用法"), + ("per-herb-1440.png", 1440, 940, "候选与逐味"), + ("sources-1440.png", 1440, 940, "资料与缺口"), + ("history-1440.png", 1440, 940, "历史与趋势"), + ("pipeline-1440.png", 1440, 940, "处理进度"), + ): + dialog.resize(width, height) + dialog.tabs.setCurrentIndex(names[tab]) + for _ in range(4): + application.processEvents() + assert (dialog.width(), dialog.height()) == (width, height), (filename, dialog.size()) + assert dialog.grab().save(str(output / filename)) + print(filename, "window", width, height, "workspace", dialog.tabs.width(), dialog.tabs.height()) + dialog.tabs.setCurrentIndex(names["完整报告"]) + dialog._set_report_mode("differences") + application.processEvents() + assert dialog.grab().save(str(output / "report-differences-1440.png")) + dialog._set_report_mode("both") + dialog.tabs.setCurrentIndex(names["对比总览"]) + dialog.resize(1280, 860) + for _ in range(4): + application.processEvents() + dialog.review_model.setCurrentIndex(1) + application.processEvents() + assert dialog.grab().save(str(output / "openai-1280.png")) + dialog.review_model.setCurrentIndex(0) + repository.batch["status"] = "running" + repository.batch["models"]["openai"].update(status="running", candidate=None, comparison=None, report=None) + dialog.refresh() + application.processEvents() + assert dialog.grab().save(str(output / "partial-1280.png")) + repository.batch.update(validity="superseded", status="success") + dialog.refresh() + application.processEvents() + assert dialog.grab().save(str(output / "historical-1280.png")) + repository.batch.update(validity="current", status="running") + repository.batch["models"]["qwen"].update(status="running", candidate=None, comparison=None, report=None) + dialog.refresh() + application.processEvents() + assert dialog.grab().save(str(output / "pending-1280.png")) + # A failed model must state the reason and offer its own retry on the card itself. + repository.batch.update(validity="current", status="partial") + repository.batch["models"]["qwen"].update( + status="failed", error_code="upstream_timeout", error_message="上游模型超时,未返回结果", + candidate=None, comparison=None, report=None, retry_count=1, max_retry=3) + repository.batch["models"]["openai"] = deepcopy(example_batch()["models"]["openai"]) + dialog.refresh() + application.processEvents() + assert dialog.grab().save(str(output / "failed-1280.png")) + # The light theme is the same window with the other palette; capture it once. + dialog.resize(1440, 940) + dialog._switch_theme() + for _ in range(12): + application.processEvents() + dialog.repaint() + assert dialog.grab().save(str(output / "light-1440.png")) + dialog._switch_theme() + for _ in range(4): + application.processEvents() + dialog.close() + + statistics = ai.PrescriptionAiStatisticsDialog(repository, ["*"]) + statistics.resize(1240, 880) + statistics.show() + for _ in range(4): + application.processEvents() + assert statistics.grab().save(str(output / "statistics-1240.png")) + statistics.close() + print("statistics-1240.png", statistics.panel.doctors.rowCount(), "doctors") + print(output) + + +if __name__ == "__main__": + main() diff --git a/app/src/doctor_workstation/__init__.py b/app/src/doctor_workstation/__init__.py index eeb331ce5..7c9cfdc43 100644 --- a/app/src/doctor_workstation/__init__.py +++ b/app/src/doctor_workstation/__init__.py @@ -3,9 +3,9 @@ __all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"] # Single source of truth for runtime, package, installer, and executable versions. -__version__ = "1.4.2" +__version__ = "1.4.5" # 调试模式开启时,登录页显示“演示模式”和“服务器设置”。 # 正式发布请保持 False;此时程序只使用下面配置的线上域名。 -DEBUG_MODE = True +DEBUG_MODE = False ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn" diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py index 574fda8f9..c3ab78b03 100644 --- a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py +++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai.py @@ -8,7 +8,8 @@ from math import isfinite from time import monotonic from typing import Any -from PySide6.QtCore import QDate, Qt, QTimer +from PySide6.QtCore import QDate, QSize, Qt, QTimer +from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QComboBox, QDateEdit, @@ -20,8 +21,9 @@ from PySide6.QtWidgets import ( QLineEdit, QProgressBar, QPushButton, - QSizePolicy, + QScrollArea, QSplitter, + QStackedWidget, QTabWidget, QTextBrowser, QTextEdit, @@ -30,15 +32,25 @@ from PySide6.QtWidgets import ( ) from ...core.errors import AuthenticationExpiredError -from ..widgets import format_record_time, friendly_error, has_permission, run_async +from ..reception_style import body_family +from ..widgets import first_value, format_record_time, friendly_error, has_permission, run_async +from .issued_prescription_ai_console import AgreementBar, StepRail, rail_qss +from .issued_prescription_ai_glyphs import Glyph, LogoTile, glyph_icon from .issued_prescription_ai_labels import ( - EXTRA_FIELD_LABELS, - SYSTEM_LABELS, + FIELD_LABELS, + STATE_LABELS, field_text, plain_text, system_text, value_text, ) +from .issued_prescription_ai_pages import ( + CandidatesPage, + HistoryPage, + ProgressPage, + SourcesPage, + StatisticsPanel, +) from .issued_prescription_ai_progress import ( ACTIVE_STATES, COMPACT_STAGES, @@ -46,50 +58,231 @@ from .issued_prescription_ai_progress import ( flow_text, progress_view, ) +from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE +from .issued_prescription_ai_theme import apply_window_chrome, on_theme_changed, toggle_theme +from .issued_prescription_ai_workspace import PrescriptionReviewWorkspace + +# The six destinations of the approved design, in order. Raw saved text is reachable from the +# in-page links, not from the navigation, so the bar stays readable at a glance. +NAV_PRIMARY = (("overview", "对比总览"), ("report", "完整报告"), ("per_herb", "候选与逐味"), + ("gaps", "资料与缺口"), ("pipeline", "处理进度"), ("history", "历史与趋势")) MODELS = {"qwen": "千问", "openai": "OpenAI"} # One calm clinical palette. The agreement number is deliberately neutral ink: a high or low # percentage is not a grade, so it never gets a green or red treatment. -INK, MUTED, LINE, CARD, PAGE = "#1f2933", "#6b7a8c", "#e3e8ef", "#ffffff", "#f4f6f9" -CHIP_TONES = { - "neutral": ("#eef2f7", "#4a5a6b"), - "info": ("#e8f1fd", "#1b4f9c"), - "ok": ("#e9f6ee", "#1c6b45"), - "warn": ("#fdf3e3", "#8a5a06"), - "risk": ("#fdecec", "#a52222"), -} -DIALOG_STYLE = f""" +INK, MUTED, LINE, CARD, PAGE = (TECH_BLUE[key] for key in ("text", "muted", "line", "surface", "canvas")) + + +@on_theme_changed +def _rebuild_shades() -> None: + """The window's own shorthands follow the active palette.""" + + global INK, MUTED, LINE, CARD, PAGE + INK, MUTED, LINE, CARD, PAGE = (TECH_BLUE[key] for key in + ("text", "muted", "line", "surface", "canvas")) + CHIP_TONES.update(_chip_tones()) +def _report_css() -> str: + """The report columns' own type scale, in the palette that is active right now. + + A rebuilt body picks the new palette up: the theme switch tears the window's body down + and builds it again, so this runs once per theme. + """ + + return ( + f"p {{ margin: 8px 0; line-height: 195%; font-size: 13px; color: {TECH_BLUE['text']}; }} " + f"li {{ line-height: 195%; font-size: 13px; color: {TECH_BLUE['text']}; }} " + "table { border-collapse: collapse; width: 100%; } " + f"th {{ background: {TECH_BLUE['surface_2']}; color: {TECH_BLUE['faint']}; font-weight: 500;" + " font-size: 10px; text-align: left; } " + # Cell text arrives wrapped in

, whose own margin sets the row height; the cell + # padding only has to add the side gutters. + f"td, th {{ border: 1px solid {TECH_BLUE['line_soft']}; padding: 1px 12px; }} " + f"td {{ font-size: 12px; color: {TECH_BLUE['text']}; }} " + f"p.sec {{ color: {TECH_BLUE['accent_text']}; font-size: 11px; font-weight: 600;" + " margin: 18px 0 8px; }" + ) + + +def _chip_tones() -> dict[str, tuple[str, str]]: + return { + "neutral": (TECH_BLUE["raised"], TECH_BLUE["text"]), + "info": (TECH_BLUE["selection"], TECH_BLUE["accent_text"]), + "ok": (TECH_BLUE["selection"], TECH_BLUE["accent_text"]), + "warn": (TECH_BLUE["amber_dim"], TECH_BLUE["amber_text"]), + "risk": (TECH_BLUE["rose_dim"], TECH_BLUE["rose_text"]), + } + + +CHIP_TONES = _chip_tones() + + +def dialog_style() -> str: + """The window's stylesheet for the palette that is active right now.""" + + return f""" QDialog {{ background: {PAGE}; }} -QLabel {{ color: {INK}; }} -QFrame#AiCard, QFrame#AiHeader, QFrame#AiReview {{ - background: {CARD}; border: 1px solid {LINE}; border-radius: 8px; +QLabel {{ color: {INK}; background: transparent; }} +QFrame#AiCard, QFrame#AiMetricCard, QFrame#AiPanel {{ + background: {CARD}; border: 1px solid {TECH_BLUE['line_soft']}; border-radius: 10px; }} -QLabel#AiIdentity {{ font-size: 15px; font-weight: 600; }} -QLabel#AiScore {{ font-size: 30px; font-weight: 700; color: {INK}; }} +QFrame#AiHeader, QFrame#AiReview {{ background: transparent; border: 0; }} +QScrollArea#AiRailScroll {{ background: transparent; border: 0; }} +QLabel#AiWindowHeading {{ font-size: 24px; font-weight: 600; color: {TECH_BLUE['heading']}; }} +QLabel#AiIdentity {{ font-size: 12px; color: {MUTED}; }} +QLabel#AiScore {{ font-size: 22px; font-weight: 600; color: {TECH_BLUE['heading']}; }} QLabel#AiScoreCaption, QLabel#AiMeta, QLabel#AiNote, QLabel#AiFootnote, QLabel#AiColumn {{ color: {MUTED}; font-size: 12px; }} -QLabel#AiFootnote {{ font-size: 11px; }} -QLabel#AiColumn {{ font-weight: 600; }} -QLabel#AiModel {{ font-size: 14px; font-weight: 600; }} -QLabel#AiStage {{ font-size: 13px; font-weight: 600; color: #245983; }} -QLabel#AiFlow {{ background: #eef4fa; color: #234d73; border-radius: 6px; padding: 7px 10px; }} -QTabWidget::pane {{ border: 1px solid {LINE}; border-radius: 8px; background: {CARD}; top: -1px; }} -QTabBar::tab {{ padding: 6px 16px; margin-right: 2px; border: 1px solid transparent; border-bottom: 0; - border-top-left-radius: 6px; border-top-right-radius: 6px; color: {MUTED}; }} -QTabBar::tab:selected {{ background: {CARD}; border-color: {LINE}; color: {INK}; font-weight: 600; }} +QLabel#AiFootnote {{ font-size: 11px; color: {TECH_BLUE['faint']}; }} +QLabel#AiColumn {{ font-size: 14px; font-weight: 600; color: {TECH_BLUE['heading']}; }} +QLabel#AiModel {{ font-size: 16px; font-weight: 600; }} +QLabel#AiStage {{ font-size: 13px; font-weight: 600; color: {TECH_BLUE['accent_text']}; }} +QLabel#AiFlow {{ background: {TECH_BLUE['surface_2']}; color: {INK}; border-radius: 6px; padding: 10px 12px; }} +QTabWidget::pane {{ border: 0; background: transparent; }} +QTextBrowser, QTextEdit {{ color: {INK}; }} QTextBrowser {{ border: 0; background: transparent; }} -QTextEdit#AiComment {{ border: 1px solid {LINE}; border-radius: 6px; background: {CARD}; padding: 3px 6px; }} +QTextEdit#AiComment {{ border: 1px solid {LINE}; border-radius: 8px; background: {TECH_BLUE['surface_2']}; + padding: 8px; font-size: 13px; color: {INK}; }} +QPushButton {{ background: {TECH_BLUE['surface_2']}; color: {INK}; border: 1px solid {LINE}; + border-radius: 7px; padding: 7px 15px; font-size: 12px; }} +QPushButton:hover {{ background: {TECH_BLUE['raised']}; border-color: {TECH_BLUE['accent']}; }} +QPushButton:focus, QComboBox:focus, QLineEdit:focus, QTextEdit:focus {{ border-color: {TECH_BLUE['accent']}; }} +QPushButton:disabled {{ color: {TECH_BLUE['faint']}; background: {TECH_BLUE['surface']}; + border-color: {TECH_BLUE['line_soft']}; }} +QPushButton#AiPrimaryAction {{ background: {TECH_BLUE['accent']}; border-color: {TECH_BLUE['accent']}; + color: #FFFFFF; font-weight: 600; }} +QPushButton#AiPrimaryAction:hover {{ background: {TECH_BLUE['accent_pressed']}; }} +QPushButton#AiPrimaryAction:disabled {{ background: {TECH_BLUE['surface_2']}; + border-color: {TECH_BLUE['line_soft']}; color: {TECH_BLUE['faint']}; }} +QPushButton#AiPager {{ padding: 6px 0; }} +QComboBox, QLineEdit {{ border: 1px solid {LINE}; border-radius: 8px; padding: 6px 10px; + min-height: 20px; color: {INK}; background: {TECH_BLUE['surface_2']}; font-size: 12px; }} +QComboBox::drop-down {{ border: 0; width: 22px; }} +QComboBox QAbstractItemView {{ background: {TECH_BLUE['surface_2']}; color: {INK}; + border: 1px solid {LINE}; selection-background-color: {TECH_BLUE['selection']}; + selection-color: {TECH_BLUE['accent_text']}; }} +QSplitter::handle {{ background: {TECH_BLUE['line_soft']}; width: 8px; }} +QMenu {{ background: {TECH_BLUE['surface_2']}; color: {INK}; border: 1px solid {LINE}; padding: 5px; }} +QMenu::item {{ padding: 8px 20px; }} +QMenu::item:selected {{ background: {TECH_BLUE['selection']}; color: {TECH_BLUE['accent_text']}; }} +QFrame#AiFacts, QFrame#AiTabsCard {{ background: transparent; border: 0; }} +QFrame#AiFactDivider {{ background: transparent; border: 0; border-left: 1px dashed {LINE}; }} +QLabel#AiHeroCrumb {{ color: {TECH_BLUE['faint']}; font-size: 10.5px; letter-spacing: 1.7px; }} +QLabel#AiHeroTitle {{ color: {TECH_BLUE['heading']}; font-size: 18.9px; font-weight: 600; }} +QLabel#AiHeroSub {{ color: {MUTED}; font-size: 10.5px; }} +QLabel#AiFactLabel {{ color: {TECH_BLUE['faint']}; font-size: 9.9px; letter-spacing: 1px; }} +QLabel#AiFactValue {{ color: {TECH_BLUE['heading']}; font-size: 13.8px; font-weight: 600; }} +QLabel#AiUserName {{ color: {INK}; font-size: 12px; }} +QScrollBar:vertical {{ background: transparent; width: 8px; margin: 0; }} +QScrollBar::handle:vertical {{ background: {TECH_BLUE['raised']}; border-radius: 4px; min-height: 30px; }} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} +QScrollBar:horizontal {{ background: transparent; height: 8px; margin: 0; }} +QScrollBar::handle:horizontal {{ background: {TECH_BLUE['raised']}; border-radius: 4px; min-width: 30px; }} +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} +QHeaderView::section {{ background: {TECH_BLUE['surface_2']}; color: {MUTED}; border: 0; + border-bottom: 1px solid {LINE}; padding: 7px 8px; font-size: 12px; }} +QTableWidget {{ background: transparent; color: {INK}; gridline-color: {LINE}; + alternate-background-color: {TECH_BLUE['zebra']}; }} +QTableWidget::item:selected {{ background: {TECH_BLUE['selection']}; color: {TECH_BLUE['heading']}; }} +QTableCornerButton::section {{ background: {TECH_BLUE['surface_2']}; border: 0; }} +QFrame#AiTopBar {{ background: {TECH_BLUE['canvas_soft']}; border: 0; + border-bottom: 1px solid {TECH_BLUE['line']}; }} """ -def _chip(text: str, tone: str = "neutral") -> QLabel: +NAV_ICONS = {"overview": "bars", "report": "doc", "per_herb": "box", + "gaps": "image", "pipeline": "clock", "history": "trend"} + + +def _glyph_button(kind: str, text: str, parent: QWidget) -> QPushButton: + """An outlined action with its icon, as the design shows them.""" + + button = QPushButton(text, parent) + button.setObjectName("AiGlyphButton") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setIcon(glyph_icon(kind, TECH_BLUE["muted"], 16)) + button.setIconSize(QSize(16, 16)) + return button + + +def _user_chip(current_user: Any, parent: QWidget) -> QWidget: + """Who is signed in. Without a session the chip states that rather than inventing a name.""" + + holder = QWidget(parent) + holder.setObjectName("AiUserChip") + row = QHBoxLayout(holder) + row.setContentsMargins(6, 2, 10, 2) + row.setSpacing(8) + name = str(first_value(current_user, "name", "real_name", "nickname", default="")).strip() + avatar = LogoTile("check", start=TECH_BLUE["accent"], end=TECH_BLUE["accent_pressed"], size=28, + radius=14, circle=True, parent=holder) + avatar.setAccessibleName(name or "当前登录医生") + row.addWidget(avatar) + caption = QLabel(name or "未登录", holder) + caption.setObjectName("AiUserName") + caption.setTextFormat(Qt.TextFormat.PlainText) + row.addWidget(caption) + chevron = Glyph("chevron", TECH_BLUE["muted"], 14, holder) + row.addWidget(chevron) + return holder + + +REVIEW_LIMIT = 500 + + +def _count_review(box: QTextEdit, meter: QLabel) -> None: + """Keep the saved comment inside the field's stated limit, and show how much is left.""" + + text = box.toPlainText() + if len(text) > REVIEW_LIMIT: + cursor = box.textCursor() + position = cursor.position() + box.blockSignals(True) + box.setPlainText(text[:REVIEW_LIMIT]) + cursor.setPosition(min(position, REVIEW_LIMIT)) + box.setTextCursor(cursor) + box.blockSignals(False) + text = box.toPlainText() + meter.setText(f"{len(text)}/{REVIEW_LIMIT}") + + +REPORT_SECTION_ORDER = ("summary", "diagnosis", "tcm_analysis", "analysis", "timeline", + "risk_assessment", "missing_information", "treatment_advice", "follow_up", + "evidence_references", "sources") + + +def report_sections(report: Any) -> list[tuple[str, str, str]]: + """The saved report split into its own fields, each with an anchor the rail can jump to. + + Order follows the reading order of the design; any field the server adds later still shows, + after the known ones, rather than being silently dropped. + """ + + data = mapping(report) + if not data: + return [] + keys = [key for key in REPORT_SECTION_ORDER if key in data] + keys += [key for key in data if key not in REPORT_SECTION_ORDER] + sections = [] + for key in keys: + value = data.get(key) + if value in (None, "", [], {}): + continue + title = field_text(key, FIELD_LABELS, STATE_LABELS) + body = f'

{escape(title)}

' + _html(value, key) + sections.append((key, title, body)) + return sections + + +def _chip(text: str, tone: str = "neutral", *, parent: QWidget) -> QLabel: """A small status pill. Text is set as plain text; tone never encodes clinical judgement.""" background, colour = CHIP_TONES.get(tone, CHIP_TONES["neutral"]) - chip = QLabel(text) + # Assign ownership before visibility; an unparented visible label becomes a window. + chip = QLabel(text, parent) chip.setTextFormat(Qt.TextFormat.PlainText) - chip.setStyleSheet(f"background: {background}; color: {colour}; border-radius: 9px; padding: 2px 9px; font-size: 12px;") + chip.setStyleSheet(f"background: {background}; color: {colour}; border-radius: 9px;" + " padding: 2px 9px; font-size: 10.2px;") chip.setVisible(bool(text)) return chip @@ -99,61 +292,6 @@ def _status_tone(status: Any) -> str: "canceled": "neutral", "running": "info", "processing": "info", "queued": "info", "retrying": "info", "retry_wait": "warn", "waiting_sources": "warn", "preparing": "info", "partial": "warn", "blocked": "warn"}.get(str(status or ""), "neutral") -STATE_LABELS = { - "blank": "尚未开方", "not_generated": "尚无分析记录", "unavailable": "暂无可比结果", - "not_applicable": "尚未开方", "not_started": "尚未分析", "pending": "待分析", - "preparing": "准备资料", "waiting_sources": "等待转写/资料", "retry_wait": "等待重试", "blocked": "需完善资料关联", - "queued": "待分析", "waiting": "等待资料", "waiting_transcript": "等待转写", - "waiting_transcription": "等待转写", "running": "分析中", "processing": "分析中", - "retrying": "重试中", "succeeded": "已完成", "completed": "已完成", "success": "已完成", - "partial": "部分完成", "failed": "需重试", "cancelled": "已取消", "canceled": "已取消", - "stale": "处方已变更", "superseded": "处方已变更", "invalid": "已失效", - "prescription_changed": "处方已变更", "source_updated": "资料已更新", "voided": "处方已作废", "deleted": "处方已删除", "revoked": "权限已撤销", - "current": "当前版本", "valid": "当前有效", "complete": "资料清单完整", - "incomplete": "资料不全", "missing": "资料缺失", "unknown": "未确认", - "needs_patient_link": "需完善患者关联", "patient_unlinked": "需完善患者关联", - "independent_baseline": "独立基线", "baseline": "独立基线", - "latest_context": "最新资料对照", "supplemental": "最新资料对照", - "assisted_revision": "AI 辅助后修订", "ai_assisted": "AI 辅助后修订", - "non_independent": "非独立对照", "auxiliary": "辅助复核", - "comparable": "可比", "not_comparable": "不可比", - "available_for_review": "供医生复核", "insufficient_data": "资料不足,暂不提供候选用药", - "withheld_for_risk": "因风险暂缓候选用药", "viewed": "已查看", "needs_information": "需补充资料", - "not_adopted": "不采纳", "reviewed": "已复核", - "per_dose": "每剂", "per_day": "每日", "matched": "共同药味", "doctor_only": "仅医生方", "candidate_only": "仅模型方", - "insufficient_sample": "样本不足", "descriptive_only": "仅作描述性统计", -} -FIELD_LABELS = { - "summary": "概要", "timeline": "病程", "analysis": "综合分析", "tcm_analysis": "中医辨证", - "diagnosis": "辨证分析", "treatment_advice": "治疗与随访建议", "risk_assessment": "需复核风险", - "evidence_references": "证据来源编号", "missing_information": "待补充资料", "level": "风险等级", "label": "说明", - "risk_warnings": "需复核风险", "risks": "风险", "follow_up": "随访建议", "evidence": "依据", - "sources": "来源", "manifest": "来源清单", "missing": "资料缺口", "status": "状态", - "reason": "原因", "name": "药名", "herb_name": "规范药名", "canonical_name": "规范药名", - "processing": "炮制", "dosage": "剂量", "dose": "剂量", "unit": "单位", "dose_basis": "剂量基准", - "formula_type": "主辅方", "doctor_dosage": "医生剂量", "candidate_dosage": "模型剂量", - "doctor_dose": "医生剂量", "candidate_dose": "模型剂量", "ai_dose": "模型剂量", - "contribution": "匹配贡献", "ratio": "匹配贡献", "match_ratio": "匹配贡献", "match": "匹配情况", - "prescription_name": "候选方名称", "prescription_type": "剂型", "herbs": "药味", - "dose_count": "剂数", "usage_days": "疗程(天)", "times_per_day": "每日服次", - "usage_instruction": "服法", "usage_time": "服药时间", "usage_way": "给药途径", - "rationale": "方义与依据", "usage_differences": "用法、疗程与风险差异", "normalization": "规范化记录", - "algorithm_version": "算法版本", "dictionary_version": "药材字典版本", "model_version": "模型版本", - "prompt_version": "提示词版本", "doctor_count": "医生药项数", "candidate_count": "候选药项数", - "matched_count": "共同药项数", "coverage": "模型资料覆盖", "source_summary": "来源汇总", - "cutoff_at": "资料截止时间", "generated_at": "报告生成时间", "comment": "复核意见", - "match_type": "增减药项", "administration_route": "给药途径", "group": "用药组", - "delivered": "已送达", "unreadable": "不可读", "unsupported": "不支持", "parsed": "已解析", - "diagnosis_count": "病历数", "prescription_count": "历史处方数", "chat_count": "聊天记录数", - "daily_record_count": "日常记录数", "transcript_count": "转写数", "attachment_count": "附件数", - "files": "附件处理清单", "source_ids": "已读取来源编号", "source_id": "来源编号", "file_id": "附件编号", - "complete": "资料清单完整", "source_complete": "文字来源齐全", "transmitted": "附件已送达", "critical": "关键资料缺口", - "baseline_eligible": "独立基线统计资格", "baseline_exclusion_reasons": "基线排除原因", "instructions": "特殊煎服说明", - "versions": "版本信息", "strata": "按版本分层", "count": "样本数", "mean": "均值", "median": "中位数", - "distribution": "一致度分布", "sample_status": "样本说明", -} -STATE_LABELS.update(SYSTEM_LABELS) -FIELD_LABELS.update(EXTRA_FIELD_LABELS) DISCLAIMER = "药味与剂量一致度衡量结构接近程度;不代表医疗准确率、安全性或疗效。候选建议仅供医生复核。" @@ -325,10 +463,62 @@ def _comparison_rows(rows: Any) -> Any: return compact +def _candidate_html(candidate: Any) -> str: + """Keep the full candidate, with its medicine list ahead of supporting detail.""" + data = mapping(candidate) + herbs = data.get("herbs") + if not isinstance(herbs, list) or not herbs or not all(isinstance(herb, Mapping) for herb in herbs): + return _html(candidate) + columns = ("name", "dosage", "unit", "dose_basis", "formula_type") + rows = [] + notes = [] + for herb in herbs: + row = {key: herb.get(key) for key in columns} + for key in ("unit", "dose_basis", "formula_type"): + if key not in herb and key in data: + row[key] = data[key] + rows.append(row) + extra = {key: value for key, value in herb.items() if key not in columns} + if extra: + notes.append(f"

{escape(plain_text(herb.get('name')))}

" + _html(extra)) + title = escape(plain_text(data.get("prescription_name") or "候选药方")) + supporting = {key: value for key, value in data.items() if key not in {"herbs", "prescription_name"}} + herb_table = _html(rows).replace('{title}

" + herb_table + "

方义与用法

" + _html(supporting) + + ("

逐味说明与来源

" + "".join(notes) if notes else "")) + + +BASIS_LABELS = {"per_dose": "每剂", "per_day": "每日", "per_bag": "每包"} + + +def _comparison_basis(batch: Any) -> str: + """One unit and one basis across every compared row, or nothing: never a guessed scale.""" + + units: set[str] = set() + bases: set[str] = set() + for key in MODELS: + comparison = mapping(mapping(mapping(batch).get("models")).get(key)).get("comparison") + rows = mapping(comparison).get("rows") + if not isinstance(rows, list): + continue + for row in rows: + value = mapping(row) + if value.get("candidate") is None and value.get("doctor") is None: + continue + units.add(str(value.get("unit") or "").strip()) + bases.add(str(value.get("dose_basis") or "").strip()) + if len(units) != 1 or len(bases) != 1: + return "" + unit, basis = units.pop(), bases.pop() + label = BASIS_LABELS.get(basis) + return f"同单位 · {label}" if unit and label else "" + + class IssuedPrescriptionAiDialog(QDialog): """Shared prescription/patient entry point; all initial and polling calls are GETs.""" - def __init__(self, repository: Any, permissions: Any, parent: QWidget | None = None, *, prescription_id: int = 0, diagnosis_id: int = 0) -> None: + def __init__(self, repository: Any, permissions: Any, parent: QWidget | None = None, *, + prescription_id: int = 0, diagnosis_id: int = 0, current_user: Any = None) -> None: super().__init__(parent) self.repository, self.permissions = repository, permissions self.prescription_id, self.diagnosis_id = prescription_id, diagnosis_id @@ -345,59 +535,153 @@ class IssuedPrescriptionAiDialog(QDialog): self._progress_frozen_seconds = 0 self._progress_stale = False self._read_access_denied = False - self.setWindowTitle("处方 AI 综合分析与用药对照") - self.resize(1200, 860) - self.setStyleSheet(DIALOG_STYLE) + self.setWindowTitle("诊断与药方 · AI 分析报告") + self.resize(1440, 940) + self.setMinimumSize(940, 640) + font = QFont(body_family()) + font.setPixelSize(13) + self.setFont(font) + self.setStyleSheet(dialog_style() + rail_qss()) + self._permissions_snapshot = permissions + self._current_user = current_user + self._pending_drafts: dict[str, tuple[str, int]] = {} + self._build_ui(permissions, current_user) + + def _build_ui(self, permissions: Any, current_user: Any) -> None: + """Construct the whole window body. Called again when the theme changes.""" + root = QVBoxLayout(self) - root.setContentsMargins(16, 14, 16, 12) + root.setContentsMargins(16, 12, 16, 10) root.setSpacing(10) - # 1) Header: which prescription this is, its state chips, and the batch actions. - header = QFrame() - header.setObjectName("AiHeader") - header_layout = QVBoxLayout(header) - header_layout.setContentsMargins(14, 10, 14, 10) - header_layout.setSpacing(6) - identity_row = QHBoxLayout() - identity_row.setSpacing(8) - self.identity = QLabel("尚无报告") - self.identity.setObjectName("AiIdentity") - self.identity.setTextFormat(Qt.TextFormat.PlainText) - identity_row.addWidget(self.identity) + # The console's own chrome: a dark top bar with the batch's identity and actions, then + # the agreement comparison, then the numbered steps beside the page they open. + top = self.top_bar = QFrame(self) + top.setObjectName("AiTopBar") + # The band wraps rather than truncates: the fact strip drops to its own line when the + # window is too narrow to hold it beside the title, as the design's own grid does. + self.top_column = QVBoxLayout(top) + self.top_column.setContentsMargins(24, 10, 24, 10) + self.top_column.setSpacing(8) + top_row = QHBoxLayout() + top_row.setContentsMargins(0, 0, 0, 0) + top_row.setSpacing(14) + self.top_column.addLayout(top_row) + identity_block = QVBoxLayout() + identity_block.setSpacing(2) + self.breadcrumb = QLabel("甄养医生工作站 / AI 分析报告", top) + self.breadcrumb.setObjectName("AiHeroCrumb") + identity_block.addWidget(self.breadcrumb) + title_row = QHBoxLayout() + title_row.setSpacing(10) + heading = QLabel("诊断与药方对照", top) + heading.setObjectName("AiHeroTitle") + title_row.addWidget(heading) self.chip_row = QHBoxLayout() self.chip_row.setSpacing(6) - identity_row.addLayout(self.chip_row) - identity_row.addStretch(1) - identity_row.addWidget(QLabel("历史批次")) - self.history = QComboBox() - self.history.setMinimumContentsLength(24) + title_row.addLayout(self.chip_row) + title_row.addStretch(1) + identity_block.addLayout(title_row) + self.identity = QLabel("尚无报告", top) + self.identity.setObjectName("AiHeroSub") + self.identity.setTextFormat(Qt.TextFormat.PlainText) + identity_block.addWidget(self.identity) + top_row.addLayout(identity_block, 1) + + self.fact_values: dict[str, QLabel] = {} + self.fact_captions: list[QLabel] = [] + self.fact_strip = QWidget(top) + strip_row = QHBoxLayout(self.fact_strip) + strip_row.setContentsMargins(0, 0, 0, 0) + strip_row.setSpacing(22) + # `label` is the module level translator; never shadow it inside this constructor. + for key, caption_text in (("prescription", "处方 / 诊单"), ("basis", "剂量口径"), + ("cutoff", "资料截止"), ("batch", "批次")): + block = QVBoxLayout() + block.setSpacing(1) + caption = QLabel(caption_text, self.fact_strip) + caption.setObjectName("AiFactLabel") + value = QLabel("—", self.fact_strip) + value.setObjectName("AiFactValue") + value.setTextFormat(Qt.TextFormat.PlainText) + block.addWidget(caption) + block.addWidget(value) + strip_row.addLayout(block) + self.fact_values[key] = value + self.fact_captions.append(caption) + strip_row.addStretch(1) + self.top_row = top_row + self._fact_strip_wrapped = False + top_row.addWidget(self.fact_strip) + + self.history = QComboBox(top) + self.history.setObjectName("AiVersionSelect") + self.history.setAccessibleName("历史分析批次") + self.history.setMinimumWidth(180) + self.history.setMaximumWidth(240) + self.history.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) + self.history.setMinimumContentsLength(12) self.history.currentIndexChanged.connect(self._history_selected) - identity_row.addWidget(self.history) - self.previous = QPushButton("上一页") - self.next = QPushButton("下一页") + top_row.addWidget(self.history) + self.previous = QPushButton("‹", top) + self.next = QPushButton("›", top) + for button, description in ((self.previous, "上一页批次"), (self.next, "下一页批次")): + button.setObjectName("AiPager") + button.setFixedWidth(28) + button.setToolTip(description) + button.setAccessibleName(description) self.previous.clicked.connect(lambda: self._history_page(-1)) self.next.clicked.connect(lambda: self._history_page(1)) - self.refresh_button = QPushButton("刷新") + top_row.addWidget(self.previous) + top_row.addWidget(self.next) + self.theme_button = QPushButton("◐", top) + self.theme_button.setObjectName("AiPager") + self.theme_button.setFixedWidth(34) + self.theme_button.setToolTip("切换深色 / 浅色") + self.theme_button.setAccessibleName("切换深色或浅色主题") + self.theme_button.clicked.connect(self._switch_theme) + top_row.addWidget(self.theme_button) + self.refresh_button = _glyph_button("refresh", "刷新", top) self.refresh_button.clicked.connect(self.refresh) - self.regenerate_button = QPushButton("重新分析") + top_row.addWidget(self.refresh_button) + self.regenerate_button = _glyph_button("bars", "重新分析", top) self.regenerate_button.clicked.connect(self._regenerate) self.regenerate_button.setVisible(has_permission(permissions, "tcm.prescriptionAi/regenerate", default=False)) self.regenerate_button.setEnabled(False) - for button in (self.previous, self.next, self.refresh_button, self.regenerate_button): - identity_row.addWidget(button) - header_layout.addLayout(identity_row) + top_row.addWidget(self.regenerate_button) + self.live_status = QLabel("正在读取报告", top) + self.live_status.setObjectName("AiHeroCrumb") + top_row.addWidget(self.live_status) + self.user_chip = _user_chip(current_user, top) + top_row.addWidget(self.user_chip) + root.addWidget(top) + + body = QWidget(self) + body_layout = QVBoxLayout(body) + body_layout.setContentsMargins(18, 14, 18, 10) + body_layout.setSpacing(10) + self.agreement = AgreementBar(body) + body_layout.addWidget(self.agreement) + self.agreement_note = QLabel( + "一致率怎么读:候选方与医方原方在「药味是否收录 × 剂量接近程度」上的加权吻合度,0–100%。" + "两侧同算法、同提示词,数值可直接横向比较。", body) + self.agreement_note.setObjectName("AiFootnote") + self.agreement_note.setWordWrap(True) + body_layout.addWidget(self.agreement_note) + self.body_row = QHBoxLayout() + self.body_row.setSpacing(14) + body_layout.addLayout(self.body_row, 1) + root.addWidget(body, 1) + self.batch_summary = QLabel("尚无报告") self.batch_summary.setObjectName("AiMeta") self.batch_summary.setTextFormat(Qt.TextFormat.PlainText) self.batch_summary.setWordWrap(True) - header_layout.addWidget(self.batch_summary) - root.addWidget(header) self.message = QLabel("正在读取已保存的报告…") self.message.setObjectName("AiMeta") self.message.setTextFormat(Qt.TextFormat.PlainText) self.message.setWordWrap(True) - root.addWidget(self.message) # 2) Batch-level progress. Hidden once nothing is running, so a finished report is not # buried under stage text that no longer changes. @@ -405,145 +689,224 @@ class IssuedPrescriptionAiDialog(QDialog): self.progress_flow.setObjectName("AiFlow") self.progress_flow.setTextFormat(Qt.TextFormat.PlainText) self.progress_flow.setWordWrap(True) - root.addWidget(self.progress_flow) self.batch_progress = QLabel() self.batch_progress.setObjectName("AiNote") self.batch_progress.setTextFormat(Qt.TextFormat.PlainText) self.batch_progress.setWordWrap(True) self.batch_progress.hide() - root.addWidget(self.batch_progress) - # 3) The headline: one card per model with its agreement number and live stage. - cards = QHBoxLayout() - cards.setSpacing(10) + # Model agreement, herb composition and source coverage as charts above the workspace. + self.model_views: dict[str, dict[str, Any]] = {} - for key, name in MODELS.items(): - card = QFrame() - card.setObjectName("AiCard") - card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - card_layout = QVBoxLayout(card) - card_layout.setContentsMargins(14, 10, 14, 12) - card_layout.setSpacing(4) - head = QHBoxLayout() - head.setSpacing(8) - model_label = QLabel(name) - model_label.setObjectName("AiModel") - head.addWidget(model_label) - status_chip = _chip("尚无报告") - head.addWidget(status_chip) - head.addStretch(1) - card_layout.addLayout(head) - score_row = QHBoxLayout() - score_row.setSpacing(8) - score = QLabel("—") - score.setObjectName("AiScore") - score.setTextFormat(Qt.TextFormat.PlainText) - score_row.addWidget(score) - caption = QLabel("药味与剂量一致度") - caption.setObjectName("AiScoreCaption") - caption.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom) - score_row.addWidget(caption) - score_row.addStretch(1) - card_layout.addLayout(score_row) - title = QLabel("尚无报告") + for key in MODELS: + views = dict(self.agreement.views(key)) + # Stage detail keeps its own widgets: the progress tab re-parents them, and a card + # must not lose its charts when that happens. + title = QLabel("尚无报告", self) title.setObjectName("AiNote") title.setTextFormat(Qt.TextFormat.PlainText) title.setWordWrap(True) - card_layout.addWidget(title) - progress_title = QLabel("等待处理进度") + progress_title = QLabel("等待处理进度", self) progress_title.setObjectName("AiStage") progress_title.setTextFormat(Qt.TextFormat.PlainText) progress_title.setWordWrap(True) - card_layout.addWidget(progress_title) - progress_bar = QProgressBar() + progress_bar = QProgressBar(self) progress_bar.setFixedHeight(6) progress_bar.setTextVisible(False) progress_bar.setRange(0, 1) progress_bar.setValue(0) - progress_bar.setStyleSheet("QProgressBar {border: 0; background: #e4ecf3; border-radius: 3px;} QProgressBar::chunk {background: #347db3; border-radius: 3px;}") - card_layout.addWidget(progress_bar) - progress_detail = QLabel() + progress_detail = QLabel("", self) progress_detail.setObjectName("AiNote") progress_detail.setTextFormat(Qt.TextFormat.PlainText) progress_detail.setWordWrap(True) - card_layout.addWidget(progress_detail) - card_layout.addStretch(1) - cards.addWidget(card, 1) - self.model_views[key] = {"card": card, "model_label": model_label, "status_chip": status_chip, - "score": score, "title": title, "progress_title": progress_title, - "progress_bar": progress_bar, "progress_detail": progress_detail} - root.addLayout(cards) + views.update({"title": title, "progress_title": progress_title, + "progress_bar": progress_bar, "progress_detail": progress_detail}) + self.model_views[key] = views + cards = QHBoxLayout() + cards.setSpacing(10) - # 4) One tab bar for both models: the same section is always compared side by side. - self.tabs = QTabWidget() - for tab_key, tab_name in (("report", "综合分析"), ("candidate", "候选用药"), ("comparison", "逐味对照"), ("sources", "来源与缺口")): + # Navigation stays compact; supporting reports remain available in the menu. + self.tabs = QTabWidget(self) + self.tabs.setAccessibleName("药方分析内容") + self.tabs.tabBar().hide() + self._current_page = "overview" + self.tab_pages: dict[str, QWidget] = {} + self.rail = StepRail(NAV_PRIMARY, self) + self.rail.selected.connect(self._goto) + self.rail.save_requested.connect(lambda: self._review(self.review_model.currentData())) + self.nav_buttons = self.rail.buttons + # A short window must scroll the rail, not squeeze it: below its minimum the focus rows + # collapse to nothing and the card reads as four empty bars. + self.rail_scroll = QScrollArea(self) + self.rail_scroll.setObjectName("AiRailScroll") + self.rail_scroll.setWidget(self.rail) + self.rail_scroll.setWidgetResizable(True) + self.rail_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.rail_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.rail_scroll.setFixedWidth(self.rail.width()) + self.body_row.addWidget(self.rail_scroll) + + self.tabs.currentChanged.connect(self._navigation_changed) + self.comparison_panel = PrescriptionReviewWorkspace(self.tabs) + self.comparison_panel.open_report.connect(self._open_supporting_report) + self.tabs.addTab(self.comparison_panel, "对比总览") + self.tab_pages["overview"] = self.comparison_panel + self.report_pages: dict[str, QWidget] = {} + self.report_sections: dict[str, list[tuple[str, str, str]]] = {key: [] for key in MODELS} + for tab_key, tab_name in (("candidate", "方义与用法"), ("report", "完整报告"), ("comparison", "逐味原文"), ("sources", "来源原文")): page = QWidget() - page_layout = QVBoxLayout(page) - page_layout.setContentsMargins(10, 8, 10, 10) + page_row = QHBoxLayout(page) + page_row.setContentsMargins(10, 8, 10, 10) + page_row.setSpacing(10) + if tab_key == "report": + page_row.addWidget(self._report_rail(page)) + page_body = QWidget(page) + page_layout = QVBoxLayout(page_body) + page_layout.setContentsMargins(0, 0, 0, 0) page_layout.setSpacing(6) + page_row.addWidget(page_body, 1) splitter = QSplitter(Qt.Orientation.Horizontal) for key, name in MODELS.items(): column = QWidget() column_layout = QVBoxLayout(column) column_layout.setContentsMargins(0, 0, 0, 0) column_layout.setSpacing(4) + # Each column names its model, its state and when that text was generated, so a + # half-finished batch never reads as two comparable reports. + head = QHBoxLayout() + head.setSpacing(8) column_title = QLabel(name) column_title.setObjectName("AiColumn") - column_layout.addWidget(column_title) + head.addWidget(column_title) + state = QLabel("") + state.setTextFormat(Qt.TextFormat.PlainText) + head.addWidget(state) + head.addStretch(1) + generated = QLabel("") + generated.setObjectName("AiNote") + generated.setTextFormat(Qt.TextFormat.PlainText) + head.addWidget(generated) + column_layout.addLayout(head) + self.model_views[key][f"{tab_key}_state"] = state + self.model_views[key][f"{tab_key}_generated"] = generated browser = _browser() + browser.document().setDefaultFont(self.font()) + browser.document().setDefaultStyleSheet(_report_css()) column_layout.addWidget(browser, 1) splitter.addWidget(column) self.model_views[key][tab_key] = browser page_layout.addWidget(splitter, 1) + if tab_key == "report": + self.report_columns = {key: self.model_views[key]["report"].parentWidget() for key in MODELS} self.tabs.addTab(page, tab_name) - root.addWidget(self.tabs, 1) + self.report_pages[tab_key] = page + self.tab_pages[{"comparison": "comparison_raw", "sources": "sources_raw"}.get(tab_key, tab_key)] = page + self.candidates_page = CandidatesPage(self.tabs) + self.tab_pages["per_herb"] = self._add_scrolled_tab(self.candidates_page, "候选与逐味") + self.sources_page = SourcesPage(self.tabs) + self.tab_pages["gaps"] = self._add_scrolled_tab(self.sources_page, "资料与缺口") + self.history_page = HistoryPage(self.tabs) + self.tab_pages["history"] = self._add_scrolled_tab(self.history_page, "历史与趋势") + self.body_row.addWidget(self.tabs, 1) - # 5) Review actions for both models in one compact strip. - review = QFrame() + progress_page = QWidget(self.tabs) + progress_layout = QVBoxLayout(progress_page) + progress_layout.setContentsMargins(20, 18, 20, 18) + progress_layout.setSpacing(14) + progress_layout.addLayout(cards) + progress_layout.addWidget(self.batch_summary) + progress_layout.addWidget(self.progress_flow) + progress_layout.addWidget(self.batch_progress) + # One progress tab only: the batch flow above, then per-model stages with the call records. + self.pipeline_page = ProgressPage(progress_page) + for key in MODELS: + self.pipeline_page.attach_live( + key, [self.model_views[key][field] + for field in ("title", "progress_title", "progress_bar", "progress_detail")]) + progress_layout.addWidget(self.pipeline_page) + # Like the other pages: state the height the stages and the call log need, and scroll. + self.tab_pages["pipeline"] = self._add_scrolled_tab(progress_page, "处理进度") + self.original_view = _browser() + self.original_view.setAccessibleName("当前批次原方完整记录") + self.original_view.document().setDefaultFont(self.font()) + self.tabs.addTab(self.original_view, "原方记录") + self.tab_pages["original"] = self.original_view + + # Keep each model's draft independent while editing in the annotation rail. + review = QFrame(self.tabs) review.setObjectName("AiReview") - review_layout = QHBoxLayout(review) - review_layout.setContentsMargins(14, 8, 14, 8) - review_layout.setSpacing(10) - for index, (key, name) in enumerate(MODELS.items()): - if index: - separator = QFrame() - separator.setFrameShape(QFrame.Shape.VLine) - separator.setStyleSheet(f"color: {LINE};") - review_layout.addWidget(separator) - row = QHBoxLayout() + review_layout = QVBoxLayout(review) + review_layout.setContentsMargins(0, 0, 0, 0) + review_layout.setSpacing(6) + self.review_model = QComboBox(review) + self.review_model.setObjectName("AiReviewModel") + self.review_model.setAccessibleName("选择复核模型") + self.review_model.setFixedWidth(116) + for key, name in MODELS.items(): + self.review_model.addItem(f"{name}复核", key) + self.review_stack = QStackedWidget(review) + review_layout.addWidget(self.review_stack) + for key, name in MODELS.items(): + page = QWidget(self.review_stack) + row = QVBoxLayout(page) + row.setContentsMargins(0, 0, 0, 0) row.setSpacing(6) - caption = QLabel(f"{name}复核") - caption.setObjectName("AiColumn") - row.addWidget(caption) - review_state = QComboBox() + review_state = QComboBox(page) + review_state.setObjectName("AiReviewState") + review_state.setAccessibleName(f"{name}复核状态") for state in ("viewed", "needs_information", "not_adopted", "reviewed"): review_state.addItem(label(state), state) - row.addWidget(review_state) - comment = QTextEdit() + comment = QTextEdit(page) comment.setObjectName("AiComment") - comment.setPlaceholderText("复核意见(独立保存,不改写 AI 报告)") - comment.setFixedHeight(32) - comment.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - row.addWidget(comment, 1) - save = QPushButton("保存") + comment.setAccessibleName(f"{name}复核意见") + comment.setPlaceholderText("请填写您的复核意见…") + comment.setFixedHeight(86) + row.addWidget(comment) + counter = QLabel("0/500", page) + counter.setObjectName("AiMeta") + counter.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + row.addWidget(counter) + comment.textChanged.connect( + lambda box=comment, meter=counter: _count_review(box, meter)) + review_actions = QHBoxLayout() + review_actions.setSpacing(8) + review_hint = QLabel("复核状态", page) + review_hint.setObjectName("AiMeta") + review_actions.addWidget(review_hint) + review_actions.addWidget(review_state) + review_actions.addStretch(1) + save = QPushButton("保存复核结果", review) + save.setIcon(glyph_icon("save", "#FFFFFF", 15)) + save.setIconSize(QSize(15, 15)) + save.setObjectName("AiPrimaryAction") save.setVisible(has_permission(permissions, "tcm.prescriptionAi/review", default=False)) save.clicked.connect(lambda _checked=False, model=key: self._review(model)) - row.addWidget(save) - retry = QPushButton("重试") - retry.setToolTip(f"仅重试 {name},使用原资料快照") + retry = QPushButton(f"重试 {name}", self) + retry.setObjectName("AiRetryAction") + retry.setToolTip(f"仅重试 {name},使用原资料快照,不重新读取资料") retry.setVisible(has_permission(permissions, "tcm.prescriptionAi/retry", default=False)) retry.clicked.connect(lambda _checked=False, model=key: self._retry(model)) - row.addWidget(retry) - review_layout.addLayout(row, 1) + # The failure and its remedy belong together, on the model card. + self.agreement.attach_action(key, retry) + review_actions.addWidget(save) + row.addLayout(review_actions) + row.addStretch(1) + self.review_stack.addWidget(page) self.model_views[key].update({"review_state": review_state, "comment": comment, "save": save, "retry": retry}) - root.addWidget(review) + self.review_model.currentIndexChanged.connect(self._review_model_changed) + self.comparison_panel.set_review_selector(self.review_model) + self.comparison_panel.set_review_widget(review) # 6) Footnote: the metric's meaning stays available without competing with the numbers. footer = QHBoxLayout() + footer_text = QVBoxLayout() + footer_text.setSpacing(4) + footer_text.addWidget(self.message) note = QLabel(DISCLAIMER) note.setObjectName("AiFootnote") note.setWordWrap(True) - footer.addWidget(note, 1) + footer_text.addWidget(note) + footer.addLayout(footer_text, 1) close = QPushButton("关闭") close.clicked.connect(self.reject) footer.addWidget(close, 0, Qt.AlignmentFlag.AlignBottom) @@ -556,9 +919,245 @@ class IssuedPrescriptionAiDialog(QDialog): self._progress_timer.setInterval(1000) self._progress_timer.timeout.connect(self._render_progress) self._sync_actions() + self._navigation_changed(self.tabs.currentIndex()) + + REPORT_MODES = (("both", "并排对照"), ("qwen", "仅千问"), ("openai", "仅 OpenAI"), + ("differences", "只看不同段落")) + + def _switch_theme(self) -> None: + """Swap the palette and rebuild the body; unsaved review drafts survive the switch.""" + + drafts = {key: (views["comment"].toPlainText(), views["review_state"].currentIndex()) + for key, views in self.model_views.items()} + page, batch_id = self._current_page, self.history.currentData() + toggle_theme() + apply_window_chrome(self) + self.setStyleSheet(dialog_style() + rail_qss()) + layout = self.layout() + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + child = item.layout() + if child is not None: + child.deleteLater() + QWidget().setLayout(layout) + self._pending_drafts = drafts + self._preferred_batch_id = batch_id if isinstance(batch_id, int) else None + self._build_ui(self._permissions_snapshot, self._current_user) + self._goto(page) + self.refresh() + + def _restore_drafts(self) -> None: + """Put back what the doctor had typed before the theme switch, once only.""" + + drafts, self._pending_drafts = self._pending_drafts, {} + for key, (text, state) in drafts.items(): + views = self.model_views.get(key) + if views is None: + continue + views["comment"].setPlainText(text) + views["review_state"].setCurrentIndex(max(0, state)) + + def _report_rail(self, parent: QWidget) -> QWidget: + """Sections on the left, as the design puts them: jump to one, or narrow what is shown.""" + + rail = QFrame(parent) + rail.setObjectName("AiPanel") + rail.setFixedWidth(158) + layout = QVBoxLayout(rail) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(4) + caption = QLabel("报告章节", rail) + caption.setObjectName("AiFactLabel") + layout.addWidget(caption) + self.report_toc = QVBoxLayout() + self.report_toc.setSpacing(2) + layout.addLayout(self.report_toc) + self.report_empty = QLabel("尚无已完成的报告", rail) + self.report_empty.setObjectName("AiMeta") + self.report_empty.setWordWrap(True) + layout.addWidget(self.report_empty) + layout.addSpacing(10) + display = QLabel("显示", rail) + display.setObjectName("AiFactLabel") + layout.addWidget(display) + self.report_mode = "both" + self.report_mode_buttons: dict[str, QPushButton] = {} + for key, text in self.REPORT_MODES: + button = QPushButton(text, rail) + button.setObjectName("AiStep") + button.setCheckable(True) + button.setChecked(key == "both") + button.setFixedHeight(28) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setStyleSheet( + f"QPushButton#AiStep {{ text-align: left; padding: 0 8px; color: {MUTED}; font-size: 12.5px; }}" + f"QPushButton#AiStep:checked {{ color: #FFFFFF; font-weight: 600; }}") + button.clicked.connect(lambda _checked=False, target=key: self._set_report_mode(target)) + self.report_mode_buttons[key] = button + layout.addWidget(button) + layout.addStretch(1) + return rail + + def _set_report_mode(self, mode: str) -> None: + self.report_mode = mode + for key, button in self.report_mode_buttons.items(): + button.setChecked(key == mode) + for key in MODELS: + self.report_columns[key].setVisible(mode in {"both", "differences", key}) + self._render_report_bodies() + + def _render_report_bodies(self) -> None: + """Rebuild both report columns for the current mode; sections keep their own anchors.""" + + shared = {key: dict((section[0], section[2]) for section in sections) + for key, sections in self.report_sections.items()} + for key in MODELS: + sections = self.report_sections[key] + if self.report_mode == "differences": + other = shared["openai" if key == "qwen" else "qwen"] + sections = [section for section in sections if other.get(section[0]) != section[2]] + body = "".join(section[2] for section in sections) + _set_html(self.model_views[key]["report"], body or "

本模型本批次没有可展示的报告段落。

") + + def _render_report_toc(self) -> None: + while self.report_toc.count(): + item = self.report_toc.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + seen: dict[str, str] = {} + for sections in self.report_sections.values(): + for key, title, _body in sections: + seen.setdefault(key, title) + for key, title in seen.items(): + button = QPushButton(title, self.report_empty.parentWidget()) + button.setObjectName("AiStep") + button.setFixedHeight(26) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setStyleSheet( + f"QPushButton#AiStep {{ text-align: left; padding: 0 8px; color: {INK}; font-size: 12.5px; }}" + f"QPushButton#AiStep:hover {{ color: {TECH_BLUE['accent_text']}; }}") + button.clicked.connect(lambda _checked=False, anchor=key: self._scroll_reports(anchor)) + self.report_toc.addWidget(button) + self.report_empty.setVisible(not seen) + + def _scroll_reports(self, anchor: str) -> None: + for key in MODELS: + self.model_views[key]["report"].scrollToAnchor(f"s-{anchor}") + + def _add_scrolled_tab(self, page: QWidget, title: str) -> QWidget: + """Pages state the height their cards need; a short window scrolls instead of squeezing.""" + + host = QScrollArea(self.tabs) + host.setWidgetResizable(True) + host.setFrameShape(QFrame.Shape.NoFrame) + host.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + host.setWidget(page) + self.tabs.addTab(host, title) + return host + + def _goto(self, key: str) -> None: + page = self.tab_pages.get(key) + if page is not None: + self.tabs.setCurrentWidget(page) + + def _navigation_changed(self, index: int) -> None: + widget = self.tabs.widget(index) + current = next((key for key, page in self.tab_pages.items() if page is widget), "") + self.rail.set_current(current) + self._current_page = current + self._apply_density() + + def _apply_density(self) -> None: + """A short window keeps the numbers and drops the fact strip and the derived counts.""" + + compact = self.height() < 780 + # Wrapping first, so the visibility the compact pass sets is the one that survives. + self._wrap_fact_strip(not self._fact_strip_fits()) + self._set_hero_compact(compact) + # The design's own breakpoint: below 1240 the band's extras give way before the title. + self._set_hero_narrow(self.width() < 1240) + # The comment box is the tallest thing in the review card; a short window shrinks it + # rather than letting the card push its own controls past its edge. + for views in self.model_views.values(): + comment = views.get("comment") + if comment is not None: + comment.setFixedHeight(54 if compact else 86) + checklist = self.comparison_panel.checklist + checklist.scroll.setMinimumHeight(40 if compact else 80) + if hasattr(self, "_progress_timer"): + self._render_progress() + + def _open_supporting_report(self, field: str) -> None: + self._goto({"doctor": "original", "candidate": "candidate", "report": "report", "diagnosis": "report", + "evidence": "report", "comparison": "per_herb", "sources": "gaps", + "progress": "pipeline"}.get(field, "report")) + if field in MODELS: + self.model_views[field]["report"].setFocus() + + @property + def save_review_button(self) -> QPushButton: + """The review card's own save action for the model currently being annotated.""" + + return self.model_views[self.review_model.currentData()]["save"] + + def _review_model_changed(self, index: int) -> None: + self.review_stack.setCurrentIndex(index) + self._sync_actions() + + def _set_hero_compact(self, compact: bool) -> None: + """A short window keeps the band's identity line and drops the six-column fact strip.""" + + self.fact_strip.setVisible(not compact) + for widget in (*self.fact_captions, *self.fact_values.values()): + widget.setVisible(not compact) + self.identity.hide() + + def _set_hero_narrow(self, narrow: bool) -> None: + """A narrow window drops the band's optional controls so the title is never cut.""" + + for widget in (self.previous, self.next, self.live_status): + widget.setVisible(not narrow) + self.breadcrumb.setVisible(not narrow) + self.history.setMinimumWidth(132 if narrow else 180) + + def _fact_strip_fits(self) -> bool: + """Whether the band still has room for the fact strip beside the title and the actions.""" + + margins = self.top_column.contentsMargins() + available = self.top_bar.width() - margins.left() - margins.right() + inline = self.top_row.sizeHint().width() + if self._fact_strip_wrapped: + inline += self.fact_strip.sizeHint().width() + self.top_row.spacing() + return inline <= available + + def _wrap_fact_strip(self, wrapped: bool) -> None: + """Move the fact strip onto its own line, or back beside the title.""" + + if wrapped == self._fact_strip_wrapped: + return + self._fact_strip_wrapped = wrapped + self.top_row.removeWidget(self.fact_strip) + self.top_column.removeWidget(self.fact_strip) + if wrapped: + self.top_column.addWidget(self.fact_strip) + else: + self.top_row.insertWidget(1, self.fact_strip) + + def resizeEvent(self, event: Any) -> None: + super().resizeEvent(event) + # The prescription workspace owns the first screen; band and charts yield when short. + self._apply_density() def showEvent(self, event: Any) -> None: super().showEvent(event) + apply_window_chrome(self) + self._apply_density() self.refresh() def hideEvent(self, event: Any) -> None: @@ -608,6 +1207,7 @@ class IssuedPrescriptionAiDialog(QDialog): data = mapping(result) self._feature_enabled = data.get("enabled", True) is not False rows = data.get("lists") or [] + self.history_page.set_history([mapping(row) for row in rows]) self.history.blockSignals(True) self.history.clear() self._history_scope = {} @@ -617,7 +1217,13 @@ class IssuedPrescriptionAiDialog(QDialog): if batch_id <= 0: continue self._history_scope[batch_id] = batch - self.history.addItem(f"处方 {batch.get('prescription_id', '—')} · 版本 {batch.get('prescription_revision', '—')} · {format_record_time(batch.get('created_at'))} · {label(batch.get('comparison_type'))} · {label(batch.get('validity'))}", batch_id) + created = format_record_time(batch.get("created_at")) + short_time = created[5:16] if len(created) >= 16 and created[4:5] == "-" else created + revision = batch.get("prescription_revision", "—") + caption = f"版本 {revision} · {short_time}" if self.prescription_id else f"处方 {batch.get('prescription_id', '—')} · V{revision} · {short_time}" + self.history.addItem(caption, batch_id) + self.history.setItemData(self.history.count() - 1, + f"处方 {batch.get('prescription_id', '—')} · 版本 {revision} · {created} · {label(batch.get('comparison_type'))} · {label(batch.get('validity'))}", Qt.ItemDataRole.ToolTipRole) index = self.history.findData(selected) if index >= 0: self.history.setCurrentIndex(index) @@ -626,6 +1232,9 @@ class IssuedPrescriptionAiDialog(QDialog): page_size = max(1, int(data.get("page_size") or 20)) self.previous.setEnabled(self._page > 1 and not self._busy) self.next.setEnabled(self._page * page_size < int(data.get("count") or 0) and not self._busy) + multiple_pages = self._page > 1 or int(data.get("count") or 0) > page_size + self.previous.setVisible(multiple_pages) + self.next.setVisible(multiple_pages) self.message.setText("已读取保存的历史报告。" if rows else "尚无已保存报告;查看页面不会触发分析。") if not self._feature_enabled: self.message.setText("自动分析当前未启用;可查看已保存的历史报告。") @@ -636,6 +1245,7 @@ class IssuedPrescriptionAiDialog(QDialog): self.refresh() def _history_selected(self, *_args: Any) -> None: + self.history.setToolTip(self.history.currentData(Qt.ItemDataRole.ToolTipRole) or "") self._generation += 1 self._detail_pending = False self._timer.stop() @@ -678,10 +1288,22 @@ class IssuedPrescriptionAiDialog(QDialog): self._progress_stale = False self.batch_summary.setText("尚无报告") self.identity.setText("尚无报告") + self.live_status.setText("尚无报告") self._set_chips([]) + self.candidates_page.set_batch({}) + self.sources_page.set_batch({}) + self.pipeline_page.set_batch({}) + for key in MODELS: + self.agreement.set_failure(key, "", False) + self.agreement.apply({}) + self.comparison_panel.set_batch({}) + self.original_view.clear() for views in self.model_views.values(): views["title"].setText("尚无报告") views["score"].setText("—") + self.report_sections[key] = [] + views["agreement_bar"].hide() + views["coverage_chip"].clear() self._set_chip(views["status_chip"], "尚无报告", "neutral") views["comment"].clear() for field in ("report", "candidate", "comparison", "sources"): @@ -741,12 +1363,14 @@ class IssuedPrescriptionAiDialog(QDialog): if (first_load or recovered) and self._feature_enabled: self.message.setText("已读取最新保存的报告;处理进度每 5 秒同步。" if batch_running(batch) else "已读取最新保存的报告。") compact_state = state_text(batch).replace("\n", " · ") + self.live_status.setText("分析中 · 自动同步" if batch_running(batch) else "已读取报告") self.identity.setText(f"处方 {batch.get('prescription_id')} · 诊单 {batch.get('diagnosis_id')} · 版本 {batch.get('prescription_revision', '—')}") self._set_chips([ (label(batch.get("validity")), "info" if current_batch(batch) else "warn"), (label(batch.get("comparison_type")), "neutral"), ]) self.batch_summary.setText(f"{compact_state} {_reason(batch.get('error_message') or batch.get('error_code'))}\n资料截止:{format_record_time(batch.get('cutoff_at'))} · 批次建立:{format_record_time(batch.get('created_at'))}") + self._render_facts(batch) for key in MODELS: model = mapping(mapping(batch.get("models")).get(key)) views = self.model_views[key] @@ -757,11 +1381,22 @@ class IssuedPrescriptionAiDialog(QDialog): reason = model.get("error_message") or comparison.get("reason") or model.get("reason") or model.get("error_code") or comparison.get("reason_code") status = model.get("status") or batch.get("status") views["score"].setText(percentage(score, 1)) + score_available = percentage(score) != "—" + # The painted gauge is the visible chart; this bar only carries the value. + views["agreement_bar"].setVisible(False) + if score_available: + views["agreement_bar"].setValue(round(float(score) * 10)) + views["agreement_bar"].setToolTip(f"药味与剂量一致度:{percentage(score, 1)};刻度 0–100%。") + views["coverage_chip"].setText(coverage_text) + views["coverage_chip"].setToolTip(coverage_text) views["score"].setStyleSheet("" if score is not None else f"color: {MUTED};") views["score"].setToolTip("药味与剂量一致度:结构接近程度,不代表医疗准确率、安全性或疗效。" if score is not None else "本模型本批次没有可比较的候选处方。") self._set_chip(views["status_chip"], label(status), _status_tone(status)) + for tab_key in ("candidate", "report", "comparison", "sources"): + self._set_chip(views[f"{tab_key}_state"], label(status), _status_tone(status)) + views[f"{tab_key}_generated"].setText(format_record_time(model.get("generated_at")) or "") views["title"].setText(f"{coverage_text} {_reason(reason)}".strip()) - _set_html(views["report"], _html(model.get("report") or "尚无已完成的报告。")) + self.report_sections[key] = report_sections(model.get("report")) candidate = mapping(model.get("candidate")) if candidate: candidate_content: Any = candidate @@ -774,17 +1409,35 @@ class IssuedPrescriptionAiDialog(QDialog): "missing_information": report.get("missing_information"), "risk_assessment": report.get("risk_assessment"), } - _set_html(views["candidate"], _html(candidate_content)) + _set_html(views["candidate"], _candidate_html(candidate_content)) comparison_html = f"

药味与剂量一致度:{percentage(score, 1)} 纯药味重合度:{percentage(comparison.get('herb_score'), 1)}

" - comparison_html += "

逐味贡献

" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。") + comparison_html += "

逐味贡献

" + _html(_comparison_rows(comparison.get("rows")) or "没有可展示的逐味对照。") comparison_html += _html({field: value for field, value in comparison.items() if field not in {"score", "herb_score", "rows"}}) _set_html(views["comparison"], comparison_html) _set_html(views["sources"], _html({"source_summary": batch.get("source_summary"), "missing": batch.get("missing"), "coverage": model.get("coverage"), "baseline_eligible": batch.get("baseline_eligible"), "baseline_exclusion_reasons": batch.get("baseline_exclusion_reasons"), "cutoff_at": format_record_time(batch.get("cutoff_at")), "generated_at": format_record_time(model.get("generated_at"))})) - if first_load: + if first_load and not self._pending_drafts: review = mapping(model.get("review")) views["comment"].setPlainText(str(review.get("comment") or "")) index = views["review_state"].findData(review.get("status")) views["review_state"].setCurrentIndex(max(0, index)) + self.candidates_page.set_batch(batch) + self.sources_page.set_batch(batch) + self.pipeline_page.set_batch(batch) + self._sync_failures(batch) + self.comparison_panel.set_batch(batch) + snapshot = mapping(batch.get("doctor_snapshot")) + patient = mapping(snapshot.get("patient")) + diagnosis = mapping(snapshot.get("diagnosis")) + prescription = mapping(snapshot.get("prescription")) + original = {"患者": patient.get("name"), "性别": patient.get("gender_label"), "年龄": patient.get("age"), + "医生诊断": diagnosis.get("clinical_diagnosis") or diagnosis, + "药材": prescription.get("herbs"), + "用法记录": {key: value for key, value in prescription.items() if key != "herbs"}} + _set_html(self.original_view, "

本批次保存的医生原方

" + _html(original if snapshot else "未保存完整原方。")) + self._restore_drafts() + self._render_report_toc() + self._render_report_bodies() + self._render_console(batch) self._sync_actions() self._render_progress() if self._progress_live(): @@ -794,21 +1447,113 @@ class IssuedPrescriptionAiDialog(QDialog): self._timer.stop() self._progress_timer.stop() + def _render_console(self, batch: Any) -> None: + """Feed the agreement bar and the rail: both read the batch, never the widgets above.""" + + data = mapping(batch) + coverage, elapsed = {}, {} + for key in MODELS: + views = self.model_views[key] + coverage[key] = views["coverage_chip"].text() + elapsed[key] = views["elapsed"].text() + self.agreement.apply(data, coverage=coverage, elapsed=elapsed) + panel = self.comparison_panel + missing = data.get("missing") + gaps = panel.gap_counts(data) if hasattr(panel, "gap_counts") else {} + self.rail.set_badges({ + "overview": panel.difference_count() if hasattr(panel, "difference_count") else "", + "report": sum(1 for key in MODELS if mapping(mapping(data.get("models")).get(key)).get("report")), + "per_herb": self.candidates_page.row_count() if hasattr(self.candidates_page, "row_count") else "", + "gaps": len(missing) if isinstance(missing, list) else "", + "pipeline": sum(len(mapping(mapping(mapping(data.get("models")).get(key)).get("usage")).get("calls") or []) + for key in MODELS), + "history": self.history.count() or "", + }, tones={ + # Severity picks the badge's colour: what still needs a decision reads rose, the + # source gaps read amber, everything else stays quiet. + "overview": "hot" if gaps.get("critical") else "", + "gaps": "warn" if missing else "", + }) + self.rail.set_focus({ + "differences": panel.difference_count() if hasattr(panel, "difference_count") else None, + "critical": gaps.get("critical"), + "restricted": panel.restricted_count(data) if hasattr(panel, "restricted_count") else None, + "consensus": panel.consensus_count() if hasattr(panel, "consensus_count") else None, + }) + + def _render_facts(self, batch: Any) -> None: + snapshot = mapping(mapping(batch).get("doctor_snapshot")) + patient = mapping(snapshot.get("patient")) + prescription = mapping(snapshot.get("prescription")) + age = str(patient.get("age") or "").strip() + person = " · ".join(part for part in ( + str(patient.get("name") or "").strip(), + str(patient.get("gender_label") or "").strip(), + f"{age} 岁" if age else "") if part) + doses = str(prescription.get("dose_count") or "").strip() + formulation = " · ".join(part for part in ( + str(prescription.get("prescription_type") or "").strip(), + f"{doses} {str(prescription.get('dose_unit') or '剂').strip()}" if doses else "") if part) + # The rows say what was actually compared; the frozen prescription is the fallback. + basis = _comparison_basis(batch) or ( + "同单位 · 每剂" if str(prescription.get("dose_unit") or "").strip() in {"剂", "付"} else "") + diagnosis_text = str(mapping(snapshot.get("diagnosis")).get("clinical_diagnosis") or "").strip() + complaint = str(mapping(snapshot.get("diagnosis")).get("chief_complaint") or "").strip() + self.identity.setText(" · ".join(part for part in ( + person, f"主诉 {complaint}" if complaint else "", + " ".join(diagnosis_text.split()), formulation) if part) or "尚无冻结快照") + values = { + "prescription": f"{batch.get('prescription_id') or '—'} / {batch.get('diagnosis_id') or '—'}", + "batch": f"#{batch.get('id')}" if mapping(batch).get("id") else "—", + "diagnosis": str(mapping(snapshot.get("diagnosis")).get("clinical_diagnosis") or "").strip(), + "formulation": formulation, + "cutoff": format_record_time(mapping(batch).get("cutoff_at")), + "basis": basis, + } + for key, widget in self.fact_values.items(): + text = str(values.get(key) or "").strip() + widget.setText(text or "—") + widget.setToolTip(text) + + def _sync_failures(self, batch: Any) -> None: + """Show why a model stopped and whether a retry is still available.""" + + data = mapping(batch) + retry_allowed = has_permission(self.permissions, "tcm.prescriptionAi/retry", default=False) + for key, name in MODELS.items(): + model = mapping(mapping(data.get("models")).get(key)) + if model.get("status") != "failed": + self.agreement.set_failure(key, "", False) + continue + reason = _reason(model.get("error_message") or model.get("error_code")) or "模型未能完成本次生成" + retryable = bool(retry_allowed and current_batch(data) and not self._progress_stale and self._feature_enabled) + exhausted = int(mapping(model.get("progress")).get("manual_retries") or model.get("manual_retries") or 0) >= 2 + if exhausted: + self.agreement.set_failure( + key, f"{name} 失败:{reason}。手动重试次数已用完,请使用“重新分析”生成新批次。", False) + else: + self.agreement.set_failure( + key, f"{name} 失败:{reason}。可仅重试本模型,使用原资料快照。", retryable) + def _set_chip(self, chip: QLabel, text: str, tone: str) -> None: background, colour = CHIP_TONES.get(tone, CHIP_TONES["neutral"]) chip.setText(text) - chip.setStyleSheet(f"background: {background}; color: {colour}; border-radius: 9px; padding: 2px 9px; font-size: 12px;") + chip.setStyleSheet(f"background: {background}; color: {colour}; border-radius: 9px;" + " padding: 2px 9px; font-size: 10.2px;") chip.setVisible(bool(text)) def _set_chips(self, chips: Any) -> None: - while self.chip_row.count(): - item = self.chip_row.takeAt(0) - widget = item.widget() + visible = [(text, tone) for text, tone in chips if text] + for index, (text, tone) in enumerate(visible): + if index < self.chip_row.count(): + self._set_chip(self.chip_row.itemAt(index).widget(), text, tone) + else: + self.chip_row.addWidget(_chip(text, tone, parent=self.chip_row.parentWidget())) + while self.chip_row.count() > len(visible): + widget = self.chip_row.takeAt(self.chip_row.count() - 1).widget() if widget is not None: + widget.hide() widget.deleteLater() - for text, tone in chips: - if text: - self.chip_row.addWidget(_chip(text, tone)) def _progress_live(self) -> bool: return self.isVisible() and self._feature_enabled and not self._busy and not self._progress_stale and batch_running(self._batch) and can_open_issued_ai(self.permissions) @@ -824,7 +1569,7 @@ class IssuedPrescriptionAiDialog(QDialog): # A finished batch does not need a stage strip competing with the results. self.progress_flow.setVisible(bool(self._batch) and (live or batch_running(self._batch) or historical)) batch_progress = progress_view(self._batch, seconds=seconds, live=live) - self.batch_progress.setVisible(bool(self._batch.get("progress")) and self.progress_flow.isVisible()) + self.batch_progress.setVisible(bool(self._batch.get("progress")) and not self.progress_flow.isHidden()) per_model = batch_progress.stage == "unknown" and batch_running(self._batch) and bool(self._batch.get("models")) batch_headline = "双模型分别处理中" if per_model else batch_progress.headline self.batch_progress.setText(prefix + batch_headline + ("\n" + batch_progress.detail if batch_progress.detail else "")) @@ -850,6 +1595,9 @@ class IssuedPrescriptionAiDialog(QDialog): bar.setValue(0) bar.hide() views["progress_title"].setVisible(progress.busy or bool(progress.detail)) + if "stage" in views: + views["stage"].setText(progress.headline) + views["stage"].setVisible(progress.busy or bool(progress.headline) and live) views["progress_detail"].setVisible(bool(progress.detail)) bar.setAccessibleName(progress.headline) bar.setToolTip("仅显示本阶段已完成的资料组数,不代表整体完成比例。" if progress.total is not None else progress.headline) @@ -882,6 +1630,8 @@ class IssuedPrescriptionAiDialog(QDialog): views["save"].setEnabled(active and not self._progress_stale and reviewable) views["review_state"].setEnabled(active and reviewable) views["comment"].setEnabled(active and reviewable) + selected = self.review_model.currentData() + self.save_review_button.setToolTip(f"保存 {MODELS[selected]} 的复核意见") def _mutate(self, operation: Any, success: str) -> None: if self._busy: @@ -950,7 +1700,8 @@ class PrescriptionAiStatisticsDialog(QDialog): self.repository, self.permissions = repository, permissions self._generation = 0 self.setWindowTitle("医生处方 AI 一致度统计") - self.resize(1060, 700) + self.setStyleSheet(dialog_style() + rail_qss()) + self.resize(1180, 860) root = QVBoxLayout(self) note = QLabel("按首次独立基线汇总;后续修订与重试不增加样本。" + DISCLAIMER) note.setWordWrap(True) @@ -975,11 +1726,18 @@ class PrescriptionAiStatisticsDialog(QDialog): self.summary.setTextFormat(Qt.TextFormat.PlainText) self.summary.setWordWrap(True) root.addWidget(self.summary) + # Headline counts, sample funnel and per-doctor rows first; the saved breakdown with the + # version strata stays below, because that detail is what makes a number interpretable. + self.panel = StatisticsPanel(self) + root.addWidget(self.panel, 4) self.report = _browser() + self.report.document().setDefaultStyleSheet(_report_css()) + self.report.setMinimumHeight(130) root.addWidget(self.report, 1) def showEvent(self, event: Any) -> None: super().showEvent(event) + apply_window_chrome(self) self.refresh() def hideEvent(self, event: Any) -> None: @@ -1015,13 +1773,15 @@ class PrescriptionAiStatisticsDialog(QDialog): data = mapping(result) if data.get("enabled") is False: self.summary.setText("当前未启用 AI 分析统计。") + self.panel.set_statistics({}) self.report.clear() return self.summary.setText(f"范围内开方事件:{label(data.get('total_count'))} · 患者数:{label(data.get('patient_count'))}。小样本应谨慎解读,未按分数排名。") + self.panel.set_statistics(data) sections = [] for row in data.get("doctors") or []: doctor = mapping(row) - sections.append(f"

{escape(str(doctor.get('doctor_name') or '未命名医生'))}

开方事件:{escape(label(doctor.get('total_count')))} · 患者数:{escape(label(doctor.get('patient_count')))} · 双模型共同有效样本:{escape(label(doctor.get('paired_count')))}

") + sections.append(f"

{escape(str(doctor.get('doctor_name') or '未命名医生'))}

开方事件:{escape(label(doctor.get('total_count')))} · 患者数:{escape(label(doctor.get('patient_count')))} · 双模型共同有效样本:{escape(label(doctor.get('paired_count')))}

") for key, name in MODELS.items(): model = mapping(mapping(doctor.get("models")).get(key)) sections.append(f"

{name} 有效比较数:{escape(label(model.get('eligible_count')))} 覆盖率:{percentage(model.get('coverage_rate'), 1)} 均值:{percentage(model.get('mean'), 1)} 中位数:{percentage(model.get('median'), 1)}

") @@ -1045,10 +1805,14 @@ class PrescriptionAiStatisticsDialog(QDialog): self.report.setHtml("".join(sections) or "

当前范围内没有可展示的统计记录。

") -def present_issued_prescription_ai(repository: Any, permissions: Any, parent: QWidget, *, prescription_id: int = 0, diagnosis_id: int = 0) -> IssuedPrescriptionAiDialog | None: +def present_issued_prescription_ai(repository: Any, permissions: Any, parent: QWidget, *, prescription_id: int = 0, + diagnosis_id: int = 0, current_user: Any = None) -> IssuedPrescriptionAiDialog | None: if not can_open_issued_ai(permissions): return None - dialog = IssuedPrescriptionAiDialog(repository, permissions, parent, prescription_id=prescription_id, diagnosis_id=diagnosis_id) + dialog = IssuedPrescriptionAiDialog(repository, permissions, parent, prescription_id=prescription_id, + diagnosis_id=diagnosis_id, + current_user=current_user if current_user is not None + else getattr(parent, "current_user", None)) dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) dialog.show() return dialog diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_charts.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_charts.py new file mode 100644 index 000000000..adb5c40ec --- /dev/null +++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_charts.py @@ -0,0 +1,375 @@ +"""Painted charts for the prescription analysis window, in the workstation's tech blue. + +Every widget draws only what the saved report contains: a value that is missing stays visibly +absent instead of being drawn as zero, and no chart implies a medical judgement by colour. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from PySide6.QtCore import QPointF, QRectF, QSize, Qt +from PySide6.QtGui import QColor, QFont, QPainter, QPaintEvent +from PySide6.QtWidgets import QSizePolicy, QWidget + +from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE + +# Colour names resolve through the active palette at paint time, so a theme switch needs no +# rebuild here: the next repaint already draws in the new colours. + + +class _Palette: + """Attribute access into the live palette: ``COLOUR.qwen`` is always the current hue.""" + + __slots__ = ("_keys",) + + def __init__(self, **keys: str) -> None: + object.__setattr__(self, "_keys", dict(keys)) + + def __getattr__(self, name: str) -> str: + return TECH_BLUE[self._keys[name]] + + +COLOUR = _Palette(doctor="muted", qwen="qwen", openai="openai", track="raised", + limited="amber", ink="heading", muted="muted") + + +def _mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _count(value: Any) -> int: + if value is None or isinstance(value, bool): + return 0 + try: + number = int(float(value)) + except (TypeError, ValueError): + return 0 + return max(0, number) + + +class DonutGauge(QWidget): + """A ring reading one percentage. An unavailable value leaves the track empty, never zero.""" + + def __init__(self, accent: str, parent: QWidget | None = None, *, diameter: int = 88, + thickness: int = 11, track: str = COLOUR.track) -> None: + super().__init__(parent) + self._accent, self._track = accent, track + self._thickness = thickness + self._value: float | None = None + self.setFixedSize(diameter, diameter) + self.setAccessibleName("一致度环形图") + self.set_value(None) + + def set_value(self, value: Any) -> None: + parsed = None + try: + parsed = None if value is None or isinstance(value, bool) else float(value) + except (TypeError, ValueError): + parsed = None + self._value = None if parsed is None or parsed < 0 or parsed > 100 else parsed + self.setAccessibleDescription("暂无可比结果" if self._value is None else f"{self._value:.1f}%") + self.update() + + def value(self) -> float | None: + return self._value + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + inset = self._thickness / 2 + 1 + box = QRectF(inset, inset, self.width() - 2 * inset, self.height() - 2 * inset) + pen = painter.pen() + pen.setWidthF(self._thickness) + pen.setCapStyle(Qt.PenCapStyle.FlatCap) + pen.setColor(QColor(self._track)) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawEllipse(box) + if self._value: + pen.setColor(QColor(self._accent)) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + # Qt angles are sixteenths of a degree; start at twelve o'clock and run clockwise. + painter.drawArc(box, 90 * 16, -int(360 * 16 * self._value / 100)) + painter.end() + + +class VennChart(QWidget): + """Doctor / model-A / model-B herb sets with their real intersection counts.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._sets: dict[str, int] = {} + self._labels = ("医生原方", "千问", "OpenAI") + self.setMinimumHeight(96) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + def set_counts(self, counts: Mapping[str, int], labels: tuple[str, str, str] | None = None) -> None: + """Regions: doctor_only, qwen_only, openai_only, doctor_qwen, doctor_openai, qwen_openai, all.""" + + self._sets = {key: _count(value) for key, value in _mapping(counts).items()} + if labels: + self._labels = labels + total = sum(self._sets.values()) + self.setAccessibleDescription( + "三方用药交集:" + " · ".join(f"{key} {value}" for key, value in self._sets.items()) if total + else "尚无可比较的候选药方") + self.update() + + def has_data(self) -> bool: + return any(self._sets.values()) + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + if not self.has_data(): + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + # The three set names sit above and below the circles, so their bands are reserved first. + top_band, bottom_band = 18.0, 18.0 + available = max(40.0, self.height() - top_band - bottom_band) + side = min(self.width() * 0.62, available / 1.34) + radius = side / 2 + offset = radius * 0.52 + centre_x = self.width() / 2 + centre_y = top_band + available / 2 - offset * 0.1 + circles = ( + (centre_x - offset, centre_y - offset * 0.55, COLOUR.doctor), + (centre_x + offset, centre_y - offset * 0.55, COLOUR.qwen), + (centre_x, centre_y + offset * 0.75, COLOUR.openai), + ) + painter.setPen(Qt.PenStyle.NoPen) + for x, y, colour in circles: + fill = QColor(colour) + fill.setAlpha(52) + painter.setBrush(fill) + painter.drawEllipse(QPointF(x, y), radius, radius) + + font = QFont(self.font()) + font.setPixelSize(13) + font.setBold(True) + painter.setFont(font) + regions = ( + ("doctor_only", centre_x - offset * 1.5, centre_y - offset * 0.75, COLOUR.doctor), + ("qwen_only", centre_x + offset * 1.5, centre_y - offset * 0.75, COLOUR.qwen), + ("openai_only", centre_x, centre_y + offset * 1.45, COLOUR.openai), + ("doctor_qwen", centre_x, centre_y - offset * 0.95, COLOUR.ink), + ("doctor_openai", centre_x - offset * 0.85, centre_y + offset * 0.55, COLOUR.ink), + ("qwen_openai", centre_x + offset * 0.85, centre_y + offset * 0.55, COLOUR.ink), + ("all", centre_x, centre_y + offset * 0.1, COLOUR.ink), + ) + for key, x, y, colour in regions: + value = self._sets.get(key, 0) + if not value: + continue + painter.setPen(QColor(colour)) + painter.drawText(QRectF(x - 22, y - 10, 44, 20), Qt.AlignmentFlag.AlignCenter, str(value)) + + font.setPixelSize(11) + font.setBold(False) + painter.setFont(font) + painter.setPen(QColor(COLOUR.muted)) + top = centre_y - offset * 0.55 - radius - 17 + painter.setPen(QColor(COLOUR.doctor)) + painter.drawText(QRectF(0, top, centre_x - 6, 16), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._labels[0]) + painter.setPen(QColor(COLOUR.qwen)) + painter.drawText(QRectF(centre_x + 6, top, centre_x - 6, 16), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, self._labels[1]) + painter.setPen(QColor(COLOUR.openai)) + painter.drawText(QRectF(0, centre_y + offset * 0.75 + radius + 1, self.width(), 16), + Qt.AlignmentFlag.AlignCenter, self._labels[2]) + painter.end() + + def minimumSizeHint(self) -> QSize: + return QSize(200, 96) + + +class DivergingDoses(QWidget): + """Per-herb dose difference against the doctor's prescription, one row per herb.""" + + ROW = 30 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._rows: list[dict[str, Any]] = [] + self._span = 1.0 + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + def set_rows(self, rows: list[Mapping[str, Any]]) -> None: + """Each row: name, doctor, qwen, openai (floats or None), unit.""" + + self._rows = [] + for row in rows: + value = _mapping(row) + entry = {"name": str(value.get("name") or ""), "unit": str(value.get("unit") or "")} + for key in ("doctor", "qwen", "openai"): + raw = value.get(key) + entry[key] = None if raw is None or isinstance(raw, bool) else float(raw) + self._rows.append(entry) + deltas = [abs((row[key] or 0) - (row["doctor"] or 0)) + for row in self._rows for key in ("qwen", "openai") + if row[key] is not None and row["doctor"] is not None] + self._span = max(1.0, max(deltas, default=1.0)) + self.setAccessibleDescription("剂量差异:" + " · ".join( + f"{row['name']} 千问 {self._delta_text(row, 'qwen')} OpenAI {self._delta_text(row, 'openai')}" + for row in self._rows) if self._rows else "暂无可比药味") + self.setMinimumHeight(self.ROW * max(1, len(self._rows)) + 18) + self.updateGeometry() + self.update() + + def _delta_text(self, row: Mapping[str, Any], key: str) -> str: + if row.get(key) is None or row.get("doctor") is None: + return "—" + delta = row[key] - row["doctor"] + return "一致" if abs(delta) < 1e-9 else f"{delta:+g}{row.get('unit') or ''}" + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + if not self._rows: + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + label_width, value_width = 96, 96 + left = label_width + 10 + right = self.width() - value_width - 10 + middle = (left + right) / 2 + scale = max(1.0, (right - left) / 2) / self._span + font = QFont(self.font()) + font.setPixelSize(12) + painter.setFont(font) + for index, row in enumerate(self._rows): + top = index * self.ROW + 4 + painter.setPen(QColor(COLOUR.ink)) + painter.drawText(QRectF(0, top, label_width, self.ROW - 8), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, row["name"]) + painter.setPen(QColor(COLOUR.track)) + painter.drawLine(QPointF(middle, top), QPointF(middle, top + self.ROW - 10)) + for offset, key, colour in ((0, "qwen", COLOUR.qwen), (10, "openai", COLOUR.openai)): + if row[key] is None or row["doctor"] is None: + continue + delta = (row[key] - row["doctor"]) * scale + bar = QRectF(min(middle, middle + delta), top + offset, max(abs(delta), 2.0), 8) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(colour)) + painter.drawRoundedRect(bar, 3, 3) + painter.setPen(QColor(COLOUR.muted)) + painter.drawText(QRectF(right + 8, top, value_width - 8, self.ROW - 8), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + f"{self._delta_text(row, 'qwen')} / {self._delta_text(row, 'openai')}") + painter.end() + + +class WaffleCoverage(QWidget): + """One square per attachment: read, limited, or not delivered.""" + + def __init__(self, parent: QWidget | None = None, *, columns: int = 11) -> None: + super().__init__(parent) + self._columns = max(4, columns) + self._read = self._limited = self._total = 0 + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + def set_counts(self, read: int, limited: int, total: int | None = None) -> None: + self._read, self._limited = _count(read), _count(limited) + self._total = max(_count(total), self._read + self._limited) + self.setAccessibleDescription( + f"附件 {self._total} 个:已读 {self._read},受限或不支持 {self._limited}" if self._total else "本次没有附件") + rows = max(1, -(-self._total // self._columns)) if self._total else 0 + self.setMinimumHeight(rows * 16 + max(0, rows - 1) * 4) + self.updateGeometry() + self.update() + + def has_data(self) -> bool: + return self._total > 0 + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + if not self._total: + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) + gap = 4 + size = max(8.0, min(16.0, (self.width() - gap * (self._columns - 1)) / self._columns)) + for index in range(self._total): + column, row = index % self._columns, index // self._columns + colour = COLOUR.qwen if index < self._read else (COLOUR.limited if index < self._read + self._limited else COLOUR.track) + painter.setBrush(QColor(colour)) + painter.drawRoundedRect(QRectF(column * (size + gap), row * (size + gap), size, size), 4, 4) + painter.end() + + +class TrendBars(QWidget): + """Grouped bars per batch: one column per model, oldest batch first.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._points: list[dict[str, Any]] = [] + self.setMinimumHeight(170) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + def set_points(self, points: list[Mapping[str, Any]]) -> None: + """Each point: label plus qwen/openai scores; None keeps the slot visibly empty.""" + + self._points = [] + for point in points: + value = _mapping(point) + entry = {"label": str(value.get("label") or "")} + for key in ("qwen", "openai"): + raw = value.get(key) + entry[key] = None if raw is None or isinstance(raw, bool) else float(raw) + self._points.append(entry) + described = [f"{entry['label']} 千问 {self._text(entry['qwen'])} OpenAI {self._text(entry['openai'])}" + for entry in self._points] + self.setAccessibleDescription("一致度趋势:" + (" · ".join(described) or "暂无批次")) + self.setToolTip("\n".join(described) or "暂无批次") + self.update() + + @staticmethod + def _text(value: float | None) -> str: + return "—" if value is None else f"{value:.1f}%" + + def has_data(self) -> bool: + return any(point.get(key) is not None for point in self._points for key in ("qwen", "openai")) + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + if not self._points: + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + # The top band is left free so each bar can print its own figure above it. + left, right, top, bottom = 44.0, self.width() - 10.0, 24.0, self.height() - 24.0 + values = [point[key] for point in self._points for key in ("qwen", "openai") if point[key] is not None] + ceiling = max(10.0, max(values, default=10.0)) + font = QFont(self.font()) + font.setPixelSize(10) + painter.setFont(font) + for fraction in (0.0, 0.5, 1.0): + y = bottom - (bottom - top) * fraction + painter.setPen(QColor(COLOUR.track)) + painter.drawLine(QPointF(left, y), QPointF(right, y)) + painter.setPen(QColor(COLOUR.muted)) + painter.drawText(QRectF(0, y - 8, left - 6, 16), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + f"{ceiling * fraction:.0f}%") + slot = (right - left) / max(1, len(self._points)) + width = min(26.0, slot / 3.6) + # The two bars of a batch sit side by side, far enough apart for each figure to fit above. + gap = width / 2 + 4 + for index, point in enumerate(self._points): + centre = left + slot * (index + 0.5) + for offset, key, colour in ((-gap, "qwen", COLOUR.qwen), (gap, "openai", COLOUR.openai)): + value = point[key] + if value is None: + continue + height = (bottom - top) * min(1.0, value / ceiling) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(colour)) + painter.drawRoundedRect(QRectF(centre + offset - width / 2, bottom - height, width, height), 3, 3) + # The design prints each figure above its bar, in that model's own colour. + painter.setPen(QColor(colour)) + painter.drawText(QRectF(centre + offset - 21, bottom - height - 17, 42, 14), + Qt.AlignmentFlag.AlignCenter, f"{value:.1f}%") + painter.setPen(QColor(COLOUR.muted)) + painter.drawText(QRectF(centre - slot / 2, bottom + 4, slot, 16), + Qt.AlignmentFlag.AlignCenter, point["label"]) + painter.end() diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_comparison.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_comparison.py new file mode 100644 index 000000000..71f2325ad --- /dev/null +++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_comparison.py @@ -0,0 +1,644 @@ +"""Native, read-only prescription comparison using saved report snapshots only.""" + +from __future__ import annotations + +from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass, replace +from decimal import Decimal, InvalidOperation +from typing import Any + +from PySide6.QtCore import QRectF, QSize, Qt +from PySide6.QtGui import QColor, QFont, QPainter, QPaintEvent +from PySide6.QtWidgets import ( + QAbstractItemView, + QButtonGroup, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from .issued_prescription_ai_labels import EXTRA_FIELD_LABELS, SYSTEM_LABELS, system_text +from .issued_prescription_ai_progress import ACTIVE_STATES, SUCCESS_STATES + +MODEL_NAMES = {"qwen": "千问", "openai": "OpenAI"} +MODEL_COLORS = {"qwen": "#3676C8", "openai": "#268578"} +DOCTOR_COLOR = "#526479" +INK, MUTED, LINE = "#23384C", "#758395", "#E6EDF3" +_UNITS = { + "g": "克", "克": "克", "mg": "毫克", "毫克": "毫克", "kg": "千克", "千克": "千克", + "ml": "毫升", "毫升": "毫升", "l": "升", "升": "升", "iu": "国际单位", "国际单位": "国际单位", + **{unit: unit for unit in ("片", "丸", "粒", "袋", "包", "支", "滴", "枚", "个", "条", "付", "帖", "两", "钱")}, +} +_BASES = {"per_dose": "每剂", "每剂": "每剂", "per_day": "每日", "每日": "每日", "每天": "每日"} +_FORMULAS = {"main": "主方", "1": "主方", "主方": "主方", "aux": "辅方", "auxiliary": "辅方", "2": "辅方", "辅方": "辅方"} +_STALE = { + "stale": "处方已变更", "superseded": "处方已变更", "prescription_changed": "处方已变更", + "source_updated": "资料已更新", "invalid": "报告已失效", "voided": "处方已作废", + "deleted": "处方已删除", "revoked": "资料权限已变更", +} + + +def _mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _text(value: Any) -> str: + """Never stringify a structured payload, which could expose internal identifiers.""" + return str(value).strip() if isinstance(value, (str, int, float, Decimal)) and not isinstance(value, bool) else "" + + +def _reason(value: Any) -> str: + if isinstance(value, Mapping): + return _reason(value.get("reason") or value.get("message") or value.get("code")) + if isinstance(value, list): + return ";".join(filter(None, (_reason(item) for item in value))) + return system_text(value, SYSTEM_LABELS, EXTRA_FIELD_LABELS, strict=True) if _text(value) else "" + + +def _number(value: Any) -> Decimal | None: + if not _text(value): + return None + try: + number = Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + return number if number.is_finite() and number >= 0 else None + + +def _formula(value: Any) -> str: + return _FORMULAS.get(_text(value), "主辅方未注明") + + +def _metadata(value: Any) -> str: + return system_text(value, SYSTEM_LABELS, EXTRA_FIELD_LABELS, strict=True) if _text(value) else "" + + +def _join_instructions(values: list[str]) -> str: + parts = [part.strip() for value in values for part in value.split(";")] + return ";".join(dict.fromkeys(part for part in parts if part and part.lower() not in {"无", "明确无", "none"})) + + +def _instructions(snapshot: dict[str, Any]) -> str: + """Read both original herb fields and the service's normalized usage snapshot.""" + fields = ("instructions", "decoction_instruction", "special_usage", "usage_instruction", "usage_time", "usage_way") + usage = _mapping(snapshot.get("usage")) + values = [_text(owner.get(field)) for owner in (snapshot, usage) for field in fields] + if not usage: + values.append(_text(snapshot.get("usage"))) + return _join_instructions(values) + + +@dataclass(frozen=True) +class _Dose: + raw: Any + unit: str + basis: str + formula: str + processing: str + route: str + group: str + instructions: str = "" + + @property + def number(self) -> Decimal | None: + return _number(self.raw) + + @property + def scale(self) -> tuple[str, str] | None: + unit, basis = _UNITS.get(self.unit.lower()), _BASES.get(self.basis) + return (unit, basis) if unit and basis else None + + @property + def label(self) -> str: + amount = _text(self.raw) + if not amount: + return "—" + unit = _UNITS.get(self.unit.lower()) or _metadata(self.unit) or "单位未注明" + basis = _BASES.get(self.basis) or "基准未确认" + return f"{amount} {unit} / {basis}" + + @property + def identity(self) -> tuple[str, str, str, str]: + # Unknown enum values must remain distinct even when their display label is generic. + return (_FORMULAS.get(self.formula, self.formula), SYSTEM_LABELS.get(self.processing, self.processing), SYSTEM_LABELS.get(self.route, self.route), self.group) + + +@dataclass(frozen=True) +class _Row: + name: str + context: str + doctor: _Dose | None + candidate: _Dose | None + match_type: str + origin: str = "comparison" + source_details: str = "" + source_names: tuple[str, ...] = () + + @property + def scale(self) -> tuple[str, str] | None: + if self.origin != "comparison": + return None + doses = [dose for dose in (self.doctor, self.candidate) if dose is not None] + if not doses or any(dose.scale is None for dose in doses): + return None + if len({dose.scale for dose in doses}) != 1 or len({dose.identity for dose in doses}) != 1: + return None + return doses[0].scale + + @property + def incompatibility(self) -> str: + if self.origin == "uncompared": + return "未纳入对比,仅保留候选原方;医生剂量未知" + if self.origin == "original": + return "候选原方附列;对应关系未保存,不推断同药" + if self.doctor is not None and self.candidate is not None: + if self.doctor.identity != self.candidate.identity: + return "主辅方、炮制或给药分组不同,不作条形比较" + if self.doctor.scale and self.candidate.scale and self.doctor.scale != self.candidate.scale: + return "单位或剂量基准不同,不作条形比较" + return "单位或每剂/每日基准未明确,不作条形比较" + + def usage_description(self, model_name: str) -> str: + return ";".join(filter(None, ("医生:" + self.doctor.instructions if self.doctor and self.doctor.instructions else "", model_name + ":" + self.candidate.instructions if self.candidate and self.candidate.instructions else ""))) + + def description(self, model_name: str) -> str: + dosage = f"{self.name},{self.context};医生:{self.doctor.label if self.doctor else '—'};{model_name}:{self.candidate.label if self.candidate else '—'}" + return ";".join(filter(None, (dosage, self.usage_description(model_name), self.source_details))) + + +def _dose(row: dict[str, Any], side: str) -> _Dose | None: + # An explicit null snapshot takes precedence over contradictory legacy flat fields. + if side in row and row[side] is None: + return None + nested = _mapping(row.get(side)) + keys = ("doctor_dosage", "doctor_dose") if side == "doctor" else ("candidate_dosage", "candidate_dose", "ai_dose") + raw = nested.get("dosage") if "dosage" in nested else next((row[key] for key in keys if key in row), None) + if not nested and raw is None: + return None + values = {key: _text(nested[key] if key in nested else row.get(key)) for key in ("unit", "dose_basis", "formula_type", "processing", "administration_route", "group")} + instructions = _instructions(nested) or _instructions(row) + return _Dose(raw, values["unit"], values["dose_basis"], values["formula_type"], values["processing"], values["administration_route"], values["group"], instructions) + + +def _row(value: dict[str, Any]) -> _Row: + doctor, candidate = _dose(value, "doctor"), _dose(value, "candidate") + details = [] + for dose in (doctor, candidate): + if dose is not None: + detail = " · ".join(filter(None, (_formula(dose.formula), _metadata(dose.processing), _metadata(dose.route), _metadata(dose.group)))) + if detail not in details: + details.append(detail) + context = " / ".join(details) or _formula(value.get("formula_type")) + if len(details) > 1: + context = "医生与模型:" + context + return _Row(_text(value.get("name") or value.get("canonical_name") or value.get("herb_name")) or "药名未保存", context, doctor, candidate, _text(value.get("match_type"))) + + +def _original_row(herb: Any, *, origin: str) -> _Row: + saved = dict(herb) if isinstance(herb, Mapping) else {"name": _text(herb) or "药材记录需核对"} + row = _row({"name": saved.get("name"), "doctor": None, "candidate": saved, "match_type": "candidate_only"}) + note = "未纳入对比" if origin == "uncompared" else "候选原方附列 · 对应关系未保存" + return replace(row, origin=origin, context=note + " · " + row.context) + + +def _saved_rows(candidate: dict[str, Any], comparison: dict[str, Any]) -> list[_Row]: + saved = comparison.get("rows") + comparison_rows = [dict(value) for value in saved if isinstance(value, Mapping)] if isinstance(saved, list) else [] + herbs = candidate.get("herbs") + herbs = herbs if isinstance(herbs, list) else [] + result: list[_Row] = [] + covered: set[int] = set() + missing_correspondence = False + for value in comparison_rows: + row = _row(value) + if row.candidate is not None and herbs: + indices = _mapping(value.get("candidate")).get("source_rows") + # The service uses zero-based array_values indices; never infer correspondence + # from names, doses, order, or the number of normalized rows. + valid_trace = isinstance(indices, list) and bool(indices) and all(isinstance(index, int) and not isinstance(index, bool) and 0 <= index < len(herbs) for index in indices) + if valid_trace: + indices = list(dict.fromkeys(indices)) + covered.update(indices) + originals = [_original_row(herbs[index], origin="original") for index in indices] + details = ";".join(f"第 {index + 1} 项 {original.name} {original.candidate.label if original.candidate else '—'}" + ("," + original.candidate.instructions if original.candidate and original.candidate.instructions else "") for index, original in zip(indices, originals, strict=True)) + instructions = _join_instructions([row.candidate.instructions, *[original.candidate.instructions for original in originals if original.candidate]]) + row = replace(row, candidate=replace(row.candidate, instructions=instructions), source_details="候选原方记录:" + details, source_names=tuple(original.name for original in originals)) + else: + missing_correspondence = True + result.append(row) + # Normalization can omit unknown names, processing conflicts, or incompatible duplicate + # entries. Keep every unaccounted original, but do not claim its doctor counterpart. + origin = "original" if missing_correspondence else "uncompared" + result.extend(_original_row(herb, origin=origin) for index, herb in enumerate(herbs) if index not in covered) + return result + + +class _DoseChart(QWidget): + """A scrollable painted chart; the adjacent table provides native accessibility.""" + + ROW_HEIGHT = 80 + GROUP_HEIGHT = 38 + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + self.setObjectName("PrescriptionDoseChart") + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.setAccessibleName("药方逐味剂量对比图") + self.rows: list[_Row] = [] + self.groups: list[tuple[tuple[str, str] | None, list[_Row], Decimal]] = [] + self.model_key = "qwen" + self.message = "暂无药方数据" + self.bars_enabled = False + + def set_rows(self, rows: list[_Row], model_key: str, *, bars_enabled: bool, message: str) -> None: + self.rows, self.model_key, self.bars_enabled, self.message = rows, model_key, bars_enabled, message + grouped: dict[tuple[str, str] | None, list[_Row]] = {} + for row in rows: + grouped.setdefault(row.scale if bars_enabled else None, []).append(row) + self.groups = [] + for scale, members in grouped.items(): + numbers = [dose.number for row in members for dose in (row.doctor, row.candidate) if dose is not None and dose.number is not None] + self.groups.append((scale, members, max(numbers, default=Decimal(0)))) + color_name = "蓝色" if model_key == "qwen" else "青绿色" + descriptions = [message, f"医生为深灰蓝;{MODEL_NAMES[model_key]}为{color_name}。各单位与基准组独立标尺,组间长度不可比较。缺失值为—,不按零计算。"] + for row in rows: + descriptions.append(row.description(MODEL_NAMES[model_key])) + if bars_enabled and row.scale is None: + descriptions.append(row.incompatibility) + if any(dose is not None and _text(dose.raw) and dose.number is None for dose in (row.doctor, row.candidate)): + descriptions.append("非数值、负数及非有限剂量保留原值,未绘制条形。") + self.setAccessibleDescription("\n".join(descriptions)) + height = 16 + sum(self.GROUP_HEIGHT + len(members) * self.ROW_HEIGHT for _, members, _ in self.groups) + self.setMinimumHeight(max(120, height)) + self.updateGeometry() + self.update() + + def sizeHint(self) -> QSize: + return QSize(440, self.minimumHeight()) + + def minimumSizeHint(self) -> QSize: + return QSize(0, 0) + + def paintEvent(self, event: QPaintEvent) -> None: + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.fillRect(self.rect(), QColor("#FFFFFF")) + painter.setFont(self.font()) + if not self.rows: + painter.setPen(QColor(MUTED)) + painter.drawText(self.rect().adjusted(24, 12, -24, -12), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, self.message) + return + width, y = max(0, self.width() - 32), 8 + normal = QFont(self.font()) + bold = QFont(normal) + bold.setBold(True) + for scale, members, maximum in self.groups: + painter.fillRect(QRectF(16, y, width, self.GROUP_HEIGHT - 8), QColor("#F3F6F9")) + painter.setFont(bold) + painter.setPen(QColor(INK)) + heading = f"{scale[0]} · {scale[1]} 独立标尺 0–{maximum}" if scale else "原始剂量 · 不作条形比较" + painter.drawText(QRectF(24, y, max(0, width - 16), self.GROUP_HEIGHT - 8), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(heading, Qt.TextElideMode.ElideRight, max(0, width - 16))) + y += self.GROUP_HEIGHT + for row in members: + if y + self.ROW_HEIGHT >= event.rect().top() and y <= event.rect().bottom(): + painter.setFont(bold) + painter.setPen(QColor(INK)) + title = f"{row.name} · {row.context}" + painter.drawText(QRectF(16, y, width, 21), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(title, Qt.TextElideMode.ElideRight, width)) + painter.setFont(normal) + if scale is None: + painter.setPen(QColor(MUTED)) + detail = ";".join(("医生 " + (row.doctor.label if row.doctor else "—"), MODEL_NAMES[self.model_key] + " " + (row.candidate.label if row.candidate else "—"))) + painter.drawText(QRectF(16, y + 24, width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(detail, Qt.TextElideMode.ElideRight, width)) + note = row.incompatibility if self.bars_enabled else "按已保存原值列示,详见左侧药材表" + painter.drawText(QRectF(16, y + 49, width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(note, Qt.TextElideMode.ElideRight, width)) + else: + for index, (dose, color, name) in enumerate(((row.doctor, DOCTOR_COLOR, "医生"), (row.candidate, MODEL_COLORS[self.model_key], MODEL_NAMES[self.model_key]))): + bar_y = y + 24 + index * 20 + painter.setPen(QColor(color)) + painter.drawText(QRectF(16, bar_y - 5, 54, 22), Qt.AlignmentFlag.AlignVCenter, name) + value = dose.label if dose else "—" + label_width = min(max(118, painter.fontMetrics().horizontalAdvance(value) + 8), max(118, width // 2)) + bar_x, bar_width = 76, max(8, width - 66 - label_width - 12) + if dose is not None and dose.number is not None: + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor("#F0F3F7")) + painter.drawRoundedRect(QRectF(bar_x, bar_y, bar_width, 9), 3, 3) + fraction = float(dose.number / maximum) if maximum else 0.0 + if fraction > 0: + painter.setBrush(QColor(color)) + painter.drawRoundedRect(QRectF(bar_x, bar_y, bar_width * fraction, 9), 3, 3) + painter.setPen(QColor(INK)) + painter.drawText(QRectF(bar_x + bar_width + 10, bar_y - 5, label_width, 22), Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(value, Qt.TextElideMode.ElideRight, int(label_width))) + painter.setPen(QColor(LINE)) + painter.drawLine(16, y + self.ROW_HEIGHT - 6, self.width() - 16, y + self.ROW_HEIGHT - 6) + y += self.ROW_HEIGHT + + +class PrescriptionComparisonPanel(QWidget): + """Switch between saved candidate prescriptions without issuing any requests.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("PrescriptionComparisonPanel") + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._batch: dict[str, Any] = {} + self._rows: list[_Row] = [] + self._model_key = "qwen" + self._bars_enabled = False + self._chart_message = "暂无药方数据" + self._candidate_count: int | None = None + self._updating_rows = False + self.setStyleSheet(f""" + QWidget#PrescriptionComparisonPanel {{ background: transparent; }} + QFrame#PrescriptionPane {{ background: white; border: 1px solid {LINE}; border-radius: 9px; }} + QLabel {{ color: {INK}; background: transparent; }} + QLabel#PrescriptionTitle {{ font-size: 20px; font-weight: 700; }} + QLabel#PrescriptionCaption {{ color: {MUTED}; font-size: 12px; }} + QLabel#PrescriptionStatus {{ color: #5F7287; font-size: 12px; }} + QPushButton#PrescriptionModel {{ border: 1px solid #DAE4EE; background: white; color: #607388; + border-radius: 6px; padding: 6px 19px; font-weight: 600; }} + QPushButton#PrescriptionModel[model="qwen"]:checked {{ background: #EDF4FE; color: #3676C8; border-color: #AAC7EB; }} + QPushButton#PrescriptionModel[model="openai"]:checked {{ background: #EDF7F4; color: #268578; border-color: #A5D2C6; }} + QLineEdit#PrescriptionSearch {{ background: white; border: 1px solid #DAE4EE; border-radius: 6px; padding: 6px 10px; }} + QTableWidget#PrescriptionHerbs {{ background: white; border: 0; color: {INK}; gridline-color: {LINE}; }} + QTableWidget#PrescriptionHerbs::item {{ padding: 4px 7px; border-bottom: 1px solid #EDF1F5; }} + QTableWidget#PrescriptionHerbs::item:selected {{ background: #EDF4FC; color: {INK}; }} + QHeaderView::section {{ background: #F4F7FA; color: #687C91; border: 0; padding: 7px; font-weight: 600; }} + QScrollArea {{ background: white; border: 0; }} + """) + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 8, 10, 8) + layout.setSpacing(10) + toolbar = QHBoxLayout() + toolbar.setSpacing(7) + self.model_buttons: dict[str, QPushButton] = {} + self.model_group = QButtonGroup(self) + for model_key, name in MODEL_NAMES.items(): + button = QPushButton(name, self) + button.setObjectName("PrescriptionModel") + button.setProperty("model", model_key) + button.setCheckable(True) + button.setChecked(model_key == self._model_key) + button.setAccessibleName(f"查看{name}候选方与医生方对比") + button.clicked.connect(lambda _checked=False, key=model_key: self._select_model(key)) + self.model_group.addButton(button) + self.model_buttons[model_key] = button + toolbar.addWidget(button) + toolbar.addStretch(1) + self.search = QLineEdit(self) + self.search.setObjectName("PrescriptionSearch") + self.search.setPlaceholderText("搜索药名") + self.search.setAccessibleName("搜索药名,同时筛选药材表和图表") + self.search.setClearButtonEnabled(True) + self.search.setMaximumWidth(240) + self.search.textChanged.connect(self._filter_rows) + toolbar.addWidget(self.search) + layout.addLayout(toolbar) + panes = QHBoxLayout() + panes.setSpacing(12) + left = QFrame(self) + left.setObjectName("PrescriptionPane") + left.setMinimumWidth(0) + left.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding) + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(15, 13, 15, 10) + left_layout.setSpacing(6) + self.caption_label = self._label("千问 · 候选药方", left, "PrescriptionCaption") + self.name_label = self._label("暂无候选药方", left, "PrescriptionTitle") + self.name_label.setWordWrap(True) + self.name_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.usage_label = self._label("", left, "PrescriptionCaption") + self.usage_label.setWordWrap(True) + left_layout.addWidget(self.caption_label) + left_layout.addWidget(self.name_label) + left_layout.addWidget(self.usage_label) + self.herb_table = QTableWidget(0, 3, left) + self.herb_table.setObjectName("PrescriptionHerbs") + self.herb_table.setAccessibleName("候选药方药材与医生剂量对照表") + self.herb_table.setHorizontalHeaderLabels(["药材 / 主辅方", "医生剂量", "千问剂量"]) + self.herb_table.verticalHeader().hide() + self.herb_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.herb_table.horizontalHeader().setMinimumSectionSize(36) + self.herb_table.setShowGrid(False) + self.herb_table.setWordWrap(True) + self.herb_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.herb_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.herb_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.herb_table.itemSelectionChanged.connect(self._focus_chart_row) + self.herb_table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.herb_table.setMinimumSize(0, 0) + self.herb_table.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding) + left_layout.addWidget(self.herb_table, 1) + self.empty_label = self._label("选择报告后显示已保存的候选药方", left, "PrescriptionCaption") + self.empty_label.setWordWrap(True) + left_layout.addWidget(self.empty_label) + self.count_label = self._label("", left, "PrescriptionCaption") + self.count_label.setWordWrap(True) + left_layout.addWidget(self.count_label) + panes.addWidget(left, 5) + right = QFrame(self) + right.setObjectName("PrescriptionPane") + right.setMinimumWidth(0) + right.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding) + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(15, 13, 15, 10) + right_layout.setSpacing(6) + title = self._label("逐味剂量对比", right) + title.setStyleSheet("font-size: 15px; font-weight: 700;") + legend_row = QHBoxLayout() + legend_row.addWidget(title) + legend_row.addStretch() + self.legend = self._label("● 医生 ● 千问", right, "PrescriptionCaption") + self.legend.setTextFormat(Qt.TextFormat.RichText) + legend_row.addWidget(self.legend) + right_layout.addLayout(legend_row) + self.status_label = self._label("", right, "PrescriptionStatus") + self.status_label.setWordWrap(True) + self.status_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + right_layout.addWidget(self.status_label) + self.chart_scroll = QScrollArea(right) + self.chart_scroll.setWidgetResizable(True) + self.chart_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.chart_scroll.setMinimumSize(0, 0) + self.chart_scroll.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding) + self.chart_scroll.setAccessibleName("可滚动查看全部药味的剂量图") + self.chart = _DoseChart(self.chart_scroll) + self.chart_scroll.setWidget(self.chart) + right_layout.addWidget(self.chart_scroll, 1) + footnote = self._label("同组同标尺,组间勿比较;剂量差异不代表医疗优劣。", right, "PrescriptionCaption") + footnote.setWordWrap(True) + right_layout.addWidget(footnote) + panes.addWidget(right, 5) + layout.addLayout(panes, 1) + self._render() + + @staticmethod + def _label(text: str, parent: QWidget, name: str = "") -> QLabel: + label = QLabel(text, parent) + label.setTextFormat(Qt.TextFormat.PlainText) + label.setObjectName(name) + return label + + @property + def selected_model(self) -> str: + return self._model_key + + def minimumSizeHint(self) -> QSize: + return QSize(0, 0) + + def set_batch(self, batch: dict) -> None: + data = _mapping(batch) + if data == self._batch: + return + self._batch = deepcopy(data) + self._render() + + def _select_model(self, model_key: str) -> None: + if model_key == self._model_key: + return + self._model_key = model_key + self._render() + + def _state(self, model: dict[str, Any], candidate: dict[str, Any], comparison: dict[str, Any]) -> tuple[bool, str]: + if not self._batch: + return False, "选择一份报告后,查看已保存的候选药方与剂量对比。" + validity = _text(self._batch.get("validity")) + if validity and validity not in {"current", "valid"}: + return False, (_STALE.get(validity) or "报告有效性未确认") + ";仅查看历史原值,暂停条形比较。" + status = _text(model.get("status")) + candidate_status = _text(candidate.get("status")) + if status in ACTIVE_STATES or candidate_status in ACTIVE_STATES: + return False, f"{MODEL_NAMES[self._model_key]}正在生成候选方,完成后显示剂量对比。" + if status in _STALE: + return False, _STALE[status] + ";仅查看历史原值。" + if status in {"failed", "cancelled", "canceled", "blocked"}: + return False, "候选方分析未完成;已保存内容仅供查看。" + if candidate_status in {"insufficient_data", "withheld_for_risk", "no_medication", "no_medication_recommended"}: + headline = {"insufficient_data": "资料不足,暂未提供候选药方", "withheld_for_risk": "因风险暂缓候选用药", "no_medication": "建议暂不使用药物", "no_medication_recommended": "建议暂不使用药物"}[candidate_status] + return False, headline + (";" + _reason(candidate.get("reason")) if candidate.get("reason") else "。") + if status and status not in SUCCESS_STATES | {"partial"}: + return False, "模型状态未确认;仅列示已保存原值。" + if candidate_status and candidate_status not in {"available_for_review", "success", "succeeded", "completed"}: + return False, "候选方状态未确认;仅列示已保存原值。" + if comparison.get("status") == "not_comparable": + return False, "本报告不可比:" + (_reason(comparison.get("reason") or comparison.get("reason_code")) or "未通过单位、剂量或资料完整性核验。") + saved_rows = comparison.get("rows") + if not isinstance(saved_rows, list) or not any(isinstance(row, Mapping) for row in saved_rows): + return False, "尚无已保存的逐味对比;医生剂量以—表示,不推算历史处方。" + if comparison.get("status") != "comparable": + return False, "可比状态未确认;仅列示已保存原值。" + prefix = "报告有效性未注明;" if not validity else "" + return True, prefix + "按明确单位与每剂/每日基准分组;缺失剂量不按零计算。" + + def _render(self) -> None: + model = _mapping(_mapping(self._batch.get("models")).get(self._model_key)) + candidate, comparison = _mapping(model.get("candidate")), _mapping(model.get("comparison")) + self._rows = _saved_rows(candidate, comparison) + self._bars_enabled, self._chart_message = self._state(model, candidate, comparison) + name = MODEL_NAMES[self._model_key] + self.caption_label.setText(f"{name} · 候选药方") + candidate_herbs = candidate.get("herbs") + self._candidate_count = len(candidate_herbs) if isinstance(candidate_herbs, list) else None + has_herbs = isinstance(candidate_herbs, list) and bool(candidate_herbs) + if any(row.origin == "original" for row in self._rows): + self._chart_message += " 候选原方另行附列;历史对应关系未保存,不推断同药。" + elif comparison.get("rows") and any(row.origin == "uncompared" for row in self._rows): + self._chart_message += " 未纳入对比的候选药材已补列原值。" + empty_title = "候选方生成中" if model.get("status") in ACTIVE_STATES else "暂无候选药方" + self.name_label.setText(_text(candidate.get("prescription_name")) or ("已保存候选方" if has_herbs else empty_title)) + self.name_label.setToolTip(self.name_label.text()) + usage = [] + if _text(candidate.get("usage_instruction")): + usage.append(_text(candidate["usage_instruction"])) + if _number(candidate.get("times_per_day")) is not None: + usage.append(f"每日 {candidate['times_per_day']} 次") + if _number(candidate.get("usage_days")) is not None: + usage.append(f"共 {candidate['usage_days']} 天") + usage_text = " · ".join(usage) + self.usage_label.setText(usage_text[:100] + ("…" if len(usage_text) > 100 else "")) + self.usage_label.setToolTip(usage_text) + self.usage_label.setVisible(bool(usage_text)) + self.status_label.setText(self._chart_message) + self.legend.setText(f'● 医生  ● {name}') + self.herb_table.setHorizontalHeaderLabels(["药材 / 主辅方", "医生剂量", f"{name}剂量"]) + self._filter_rows() + + def _filter_rows(self) -> None: + self._updating_rows = True + query = self.search.text().strip().casefold() + rows = [row for row in self._rows if any(query in name.casefold() for name in (row.name, *row.source_names))] + table_scroll = self.herb_table.verticalScrollBar().value() + chart_scroll = self.chart_scroll.verticalScrollBar().value() + selected_row = self.herb_table.currentRow() + selected_name = self.herb_table.item(selected_row, 0).text() if selected_row >= 0 and self.herb_table.item(selected_row, 0) else "" + self.herb_table.setRowCount(len(rows)) + for index, row in enumerate(rows): + name_text = row.name + "\n" + row.context + instructions = row.usage_description(MODEL_NAMES[self._model_key]) + if instructions: + name_text += "\n" + instructions + values = (name_text, row.doctor.label if row.doctor else "—", row.candidate.label if row.candidate else "—") + for column, value in enumerate(values): + item = QTableWidgetItem(value) + item.setToolTip(row.description(MODEL_NAMES[self._model_key])) + item.setData(Qt.ItemDataRole.AccessibleTextRole, value) + if column == 0: + item.setData(Qt.ItemDataRole.UserRole, row) + item.setData(Qt.ItemDataRole.AccessibleDescriptionRole, row.description(MODEL_NAMES[self._model_key])) + item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + if column == 1: + item.setForeground(QColor(DOCTOR_COLOR)) + if column == 2: + item.setForeground(QColor(MODEL_COLORS[self._model_key])) + self.herb_table.setItem(index, column, item) + self.herb_table.setRowHeight(index, max(48, self.herb_table.fontMetrics().height() * (3 if instructions else 2) + 12)) + if name_text == selected_name: + self.herb_table.selectRow(index) + self.herb_table.verticalScrollBar().setValue(table_scroll) + self.herb_table.setVisible(bool(rows)) + self.empty_label.setText("没有匹配的药名,请调整搜索。" if self._rows and not rows else self._chart_message) + self.empty_label.setVisible(not rows) + comparison_count = sum(row.origin == "comparison" for row in self._rows) + uncompared_count = sum(row.origin == "uncompared" for row in self._rows) + original_count = sum(row.origin == "original" for row in self._rows) + counts = [f"候选原方 {self._candidate_count} 项" if self._candidate_count is not None else "候选原方未保存", f"对比 {comparison_count} 项"] + if uncompared_count: + counts.append(f"未纳入 {uncompared_count} 项") + if original_count: + counts.append(f"原方附列 {original_count} 项") + if query: + counts.append(f"搜索显示 {len(rows)} 项") + self.count_label.setText(" · ".join(counts)) + self.count_label.setToolTip("原方项数来自候选药材完整清单;对比项数来自已保存的标准化记录。附列药材不推断与对比记录的对应关系;— 表示该侧未保存剂量。") + self.count_label.setVisible(bool(self._rows)) + empty_message = "没有匹配的药名" if self._rows and not rows else self._chart_message + self.chart.set_rows(rows, self._model_key, bars_enabled=self._bars_enabled, message=empty_message) + self.chart_scroll.verticalScrollBar().setValue(chart_scroll) + self._updating_rows = False + + def _focus_chart_row(self) -> None: + if self._updating_rows: + return + item = self.herb_table.item(self.herb_table.currentRow(), 0) + if item is None: + return + target = item.data(Qt.ItemDataRole.UserRole) + y = 8 + for _scale, members, _maximum in self.chart.groups: + y += self.chart.GROUP_HEIGHT + for row in members: + if row is target: + self.chart_scroll.verticalScrollBar().setValue(y) + return + y += self.chart.ROW_HEIGHT diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_console.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_console.py new file mode 100644 index 000000000..4c99c2f5b --- /dev/null +++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_console.py @@ -0,0 +1,558 @@ +"""The console chrome: the agreement comparison bar and the numbered step rail. + +Both widgets read only what the saved batch carries. A model without a comparable candidate keeps +an empty track instead of a zero-length bar, and the rail's counters stay blank until the batch +actually reports them. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal +from PySide6.QtGui import ( + QBrush, + QColor, + QFont, + QFontMetricsF, + QLinearGradient, + QPainter, + QPainterPath, + QPaintEvent, + QPalette, +) +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QProgressBar, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from .issued_prescription_ai_theme import CONSOLE, MODEL_HUE, MODEL_TEXT, num_font + +MODEL_NAMES = {"qwen": "千问", "openai": "OpenAI"} + + +def _number(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed + + +def _mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _score(model: Mapping[str, Any]) -> float | None: + comparison = _mapping(_mapping(model).get("comparison")) + status = comparison.get("status") or _mapping(model).get("comparison_status") + if status != "comparable": + return None + value = _number(comparison.get("score", _mapping(model).get("score"))) + return None if value is None or value < 0 or value > 100 else value + + +class ScaleTrack(QWidget): + """A 0–100 track: the model's own fill, and a grey mark where the other model stands.""" + + # The design's scale: a 12px band for the rival's label, a 13px rail, then the axis row. + LABEL_BAND = 13.0 + RAIL_TOP = 16.0 + RAIL_HEIGHT = 13.0 + AXIS_TOP = 31.0 + + def __init__(self, model_key: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.model_key = model_key + self._value: float | None = None + self._other: float | None = None + self._other_label = "" + self.setFixedHeight(44) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + def set_values(self, value: float | None, other: float | None, other_label: str) -> None: + self._value, self._other, self._other_label = value, other, other_label + self.setAccessibleDescription("暂无可比结果" if value is None else f"{value:.1f}%") + self.setToolTip("" if other is None else f"{other_label} 在 {other:.1f}%") + self.update() + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + top, height = self.RAIL_TOP, self.RAIL_HEIGHT + radius = height / 2 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(CONSOLE["raised"])) + painter.drawRoundedRect(QRectF(0, top, self.width(), height), radius, radius) + if self._value: + filled = self.width() * self._value / 100 + hue = QColor(MODEL_HUE[self.model_key]) + faded = QColor(hue) + faded.setAlphaF(0.45) + gradient = QLinearGradient(QPointF(0, top), QPointF(filled, top)) + gradient.setColorAt(0.0, faded) + gradient.setColorAt(1.0, hue) + painter.setBrush(QBrush(gradient)) + painter.drawRoundedRect(QRectF(0, top, filled, height), radius, radius) + # Quarter dividers sit on the rail itself, as the design draws them. + painter.setPen(QColor(CONSOLE["grid_line"])) + for fraction in (0.25, 0.5, 0.75): + x = self.width() * fraction + painter.drawLine(QPointF(x, top), QPointF(x, top + height)) + axis = QFont(self.font()) + axis.setPixelSize(9) + painter.setFont(axis) + painter.setPen(QColor(CONSOLE["faint"])) + for fraction in (0.0, 0.25, 0.5, 0.75, 1.0): + label = f"{int(fraction * 100)}%" + width = 44.0 + left = self.width() * fraction - width / 2 + align = Qt.AlignmentFlag.AlignCenter + if fraction == 0.0: + left, align = 0.0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + elif fraction == 1.0: + left, align = self.width() - width, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + painter.drawText(QRectF(left, self.AXIS_TOP, width, 12), align, label) + if self._other is not None: + x = self.width() * self._other / 100 + marker = QColor(CONSOLE["muted"]) + marker.setAlphaF(0.8) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(marker) + painter.drawRoundedRect(QRectF(x - 1, top - 4, 2, height + 8), 1, 1) + painter.setPen(QColor(CONSOLE["faint"])) + painter.drawText(QRectF(max(0.0, min(x - 60, self.width() - 120)), 0, 120, self.LABEL_BAND), + Qt.AlignmentFlag.AlignCenter, f"{self._other_label} 在此") + painter.end() + + +class ScoreLabel(QLabel): + """The big figure with its unit set small and raised, the way the design prints it. + + ``text()`` still returns the whole string, so callers and tests read one plain value. + """ + + SIZE = 27 + UNIT_SIZE = 13 + + def _parts(self) -> tuple[str, str]: + text = self.text() + for unit in ("%", "pt"): + if text.endswith(unit) and len(text) > len(unit): + return text[: -len(unit)], unit + return text, "" + + def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual + figure, unit = self._parts() + width = QFontMetricsF(num_font(self.SIZE, weight=QFont.Weight.DemiBold)).horizontalAdvance(figure) + if unit: + width += 2 + QFontMetricsF(num_font(self.UNIT_SIZE)).horizontalAdvance(unit) + return QSize(int(width) + 1, int(self.SIZE * 1.1) + 1) + + def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual + return self.sizeHint() + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + figure, unit = self._parts() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + figure_font = num_font(self.SIZE, weight=QFont.Weight.DemiBold) + painter.setFont(figure_font) + painter.setPen(self.palette().color(QPalette.ColorRole.WindowText)) + metrics = QFontMetricsF(figure_font) + baseline = (self.height() + metrics.capHeight()) / 2 + painter.drawText(QPointF(0, baseline), figure) + if unit: + unit_font = num_font(self.UNIT_SIZE) + painter.setFont(unit_font) + painter.setPen(QColor(CONSOLE["muted"])) + painter.drawText(QPointF(metrics.horizontalAdvance(figure) + 2, + baseline - metrics.capHeight() + QFontMetricsF(unit_font).capHeight()), + unit) + painter.end() + + +class AgreementBar(QFrame): + """Both models' agreement on one scale, with the gap between them stated in points.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AiAgreement") + layout = QHBoxLayout(self) + layout.setContentsMargins(22, 16, 22, 16) + layout.setSpacing(24) + self.blocks: dict[str, dict[str, Any]] = {} + for index, key in enumerate(("qwen", "openai")): + if index: + divider = QFrame(self) + divider.setObjectName("AiFactDivider") + divider.setFixedWidth(1) + layout.addWidget(divider) + layout.addWidget(self._delta_block()) + divider = QFrame(self) + divider.setObjectName("AiFactDivider") + divider.setFixedWidth(1) + layout.addWidget(divider) + # Both heads line up at the top, so a model that failed does not slide its column down. + layout.addWidget(self._model_block(key), 1, Qt.AlignmentFlag.AlignTop) + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + """The design runs a blue-to-violet bar down the card's left edge, inside its rounding.""" + + super().paintEvent(event) + card = QPainterPath() + card.addRoundedRect(QRectF(1, 1, self.width() - 2, self.height() - 2), 9, 9) + strip = QPainterPath() + strip.addRect(QRectF(0, 0, 4, self.height())) + gradient = QLinearGradient(QPointF(0, 0), QPointF(0, self.height())) + gradient.setColorAt(0.0, QColor(CONSOLE["accent"])) + gradient.setColorAt(1.0, QColor(CONSOLE["openai"])) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) + painter.fillPath(card.intersected(strip), QBrush(gradient)) + painter.end() + + def _model_block(self, key: str) -> QWidget: + holder = QWidget(self) + column = QVBoxLayout(holder) + column.setContentsMargins(0, 0, 0, 0) + column.setSpacing(9) + head = QHBoxLayout() + head.setSpacing(9) + dot = QLabel(holder) + dot.setFixedSize(9, 9) + dot.setStyleSheet(f"background: {MODEL_HUE[key]}; border-radius: 3px;") + head.addWidget(dot) + name = QLabel(MODEL_NAMES[key], holder) + name.setStyleSheet(f"color: {CONSOLE['heading']}; font-size: 13.8px; font-weight: 600;") + head.addWidget(name) + status = QLabel("尚无报告", holder) + status.setTextFormat(Qt.TextFormat.PlainText) + head.addWidget(status) + coverage = QLabel("", holder) + coverage.setTextFormat(Qt.TextFormat.PlainText) + coverage.setStyleSheet( + f"background: {CONSOLE['amber_dim']}; color: {CONSOLE['amber_text']};" + f" border: 1px solid {CONSOLE['amber']}; border-radius: 9px; padding: 1px 8px; font-size: 9.9px;") + head.addWidget(coverage) + elapsed = QLabel("", holder) + elapsed.setTextFormat(Qt.TextFormat.PlainText) + elapsed.setStyleSheet( + f"background: transparent; color: {CONSOLE['faint']};" + f" border: 1px solid {CONSOLE['line']}; border-radius: 9px; padding: 1px 8px; font-size: 9.9px;") + head.addWidget(elapsed) + head.addStretch(1) + column.addLayout(head) + score = ScoreLabel("—", holder) + score.setObjectName(f"AiAgreementScore{key.capitalize()}") + score.setTextFormat(Qt.TextFormat.PlainText) + column.addWidget(score) + caption = QLabel("药味与剂量一致率", holder) + caption.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.5px;") + column.addWidget(caption) + track = ScaleTrack(key, holder) + column.addWidget(track) + + # A failed model states why it stopped and offers its retry on its own block, which is + # the only place in the console that belongs to that model alone. + failure = QWidget(holder) + failure_row = QHBoxLayout(failure) + failure_row.setContentsMargins(0, 2, 0, 0) + failure_row.setSpacing(8) + failure_text = QLabel("", failure) + failure_text.setTextFormat(Qt.TextFormat.PlainText) + failure_text.setWordWrap(True) + failure_text.setStyleSheet(f"color: {CONSOLE['rose_text']}; font-size: 12px;") + failure_row.addWidget(failure_text, 1) + retry_slot = QHBoxLayout() + retry_slot.setContentsMargins(0, 0, 0, 0) + retry_slot.setSpacing(6) + failure_row.addLayout(retry_slot) + failure.setVisible(False) + column.addWidget(failure) + + stage = QLabel("等待处理进度", holder) + stage.setTextFormat(Qt.TextFormat.PlainText) + stage.setWordWrap(True) + stage.setStyleSheet(f"color: {MODEL_TEXT[key]}; font-size: 12px;") + stage.hide() + # The plain bar stays as a value carrier for callers and accessibility tools; the painted + # track above is the visual, so it is never added to the layout. + carrier = QProgressBar(holder) + carrier.setRange(0, 1000) + carrier.setTextVisible(False) + carrier.setFixedHeight(4) + carrier.setAccessibleName(f"{MODEL_NAMES[key]}与医生方的药味及剂量一致度") + carrier.setVisible(False) + self.blocks[key] = {"score": score, "coverage": coverage, "elapsed": elapsed, "track": track, + "status": status, "failure": failure, "failure_text": failure_text, + "retry_slot": retry_slot, "stage": stage, "carrier": carrier} + return holder + + def views(self, key: str) -> dict[str, Any]: + """Widget map kept stable for the dialog and its regression tests.""" + + block = self.blocks[key] + return {"card": self, "model_label": block["score"], "status_chip": block["status"], + "coverage_chip": block["coverage"], "score": block["score"], "elapsed": block["elapsed"], + "agreement_bar": block["carrier"], "gauge": block["track"], "stage": block["stage"], + "failure": block["failure"], "failure_text": block["failure_text"]} + + def attach_action(self, key: str, button: QWidget) -> None: + """Host a dialog-owned action (retry) inside that model's failure row.""" + + self.blocks[key]["retry_slot"].addWidget(button) + + def set_failure(self, key: str, message: str, retryable: bool) -> None: + block = self.blocks[key] + block["failure_text"].setText(message) + block["failure"].setVisible(bool(message)) + slot = block["retry_slot"] + for index in range(slot.count()): + widget = slot.itemAt(index).widget() + if widget is not None: + widget.setVisible(retryable) + + def _delta_block(self) -> QWidget: + holder = QWidget(self) + holder.setFixedWidth(180) + column = QVBoxLayout(holder) + column.setContentsMargins(0, 6, 0, 0) + column.setSpacing(2) + column.addStretch(1) + self.delta = QLabel("—", holder) + self.delta.setTextFormat(Qt.TextFormat.PlainText) + self.delta.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.delta.setFont(num_font(20, weight=QFont.Weight.DemiBold)) + self.delta.setStyleSheet(f"color: {CONSOLE['accent_text']}; font-size: 19.5px; font-weight: 600;") + column.addWidget(self.delta) + self.delta_note = QLabel("等待两个模型", holder) + self.delta_note.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.delta_note.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px;") + column.addWidget(self.delta_note) + self.overlap = QLabel("", holder) + self.overlap.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.overlap.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px;") + column.addWidget(self.overlap) + column.addStretch(1) + return holder + + def apply(self, batch: Mapping[str, Any] | None, *, coverage: Mapping[str, str] | None = None, + elapsed: Mapping[str, str] | None = None) -> None: + data = _mapping(batch) + models = _mapping(data.get("models")) + scores = {key: _score(_mapping(models.get(key))) for key in ("qwen", "openai")} + for key in ("qwen", "openai"): + block = self.blocks[key] + value = scores[key] + block["score"].setText("—" if value is None else f"{value:.1f}%") + other = scores["openai" if key == "qwen" else "qwen"] + block["track"].set_values(value, other, MODEL_NAMES["openai" if key == "qwen" else "qwen"]) + text = _mapping(coverage).get(key, "") + block["coverage"].setText(text) + block["coverage"].setVisible(bool(text)) + spent = _mapping(elapsed).get(key, "") + block["elapsed"].setText(spent) + block["elapsed"].setVisible(bool(spent)) + if scores["qwen"] is None or scores["openai"] is None: + self.delta.setText("—") + self.delta_note.setText("两个模型都可比后才给差值") + self.overlap.setText("") + return + gap = scores["qwen"] - scores["openai"] + leader = MODEL_NAMES["qwen"] if gap >= 0 else MODEL_NAMES["openai"] + self.delta.setText(f"{'+' if gap >= 0 else '−'}{abs(gap):.1f}pt") + self.delta_note.setText(f"{leader}领先" if gap else "两模型持平") + overlaps = [] + for key in ("qwen", "openai"): + herb = _number(_mapping(_mapping(models.get(key)).get("comparison")).get("herb_score")) + overlaps.append("—" if herb is None else f"{herb:.1f}%") + self.overlap.setText("药味重合 " + " / ".join(overlaps)) + + +class _StepButton(QPushButton): + """A step row; the design marks the selected one with a rounded bar down its left edge.""" + + BAR_INSET = 9 + BAR_WIDTH = 3 + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + super().paintEvent(event) + if not self.isChecked(): + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(CONSOLE["accent"])) + painter.drawRoundedRect( + QRectF(0, self.BAR_INSET, self.BAR_WIDTH, self.height() - self.BAR_INSET * 2), 1.5, 1.5) + painter.end() + + +class StepRail(QFrame): + """The six destinations as numbered steps, with what this batch needs looked at below them.""" + + selected = Signal(str) + save_requested = Signal() + + FOCUS_ROWS = (("differences", "剂量差异", "项", "amber_text"), + ("critical", "关键缺口", "项", "rose_text"), + ("restricted", "附件受限", "个", "text"), + ("consensus", "三方共识", "味", "accent_text")) + + def __init__(self, steps: tuple[tuple[str, str], ...], parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AiRail") + self.setFixedWidth(232) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(12) + + nav = QFrame(self) + nav.setObjectName("AiPanel") + nav_layout = QVBoxLayout(nav) + nav_layout.setContentsMargins(6, 6, 6, 6) + nav_layout.setSpacing(0) + self._tones: dict[str, str] = {} + self.buttons: dict[str, QPushButton] = {} + self.badges: dict[str, QLabel] = {} + self.numbers: dict[str, QLabel] = {} + self.names: dict[str, QLabel] = {} + for index, (key, text) in enumerate(steps, start=1): + button = _StepButton(nav) + button.setObjectName("AiStep") + button.setCheckable(True) + button.setChecked(index == 1) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setFixedHeight(42) + button.clicked.connect(lambda _checked=False, target=key: self.selected.emit(target)) + row = QHBoxLayout(button) + row.setContentsMargins(11, 0, 11, 0) + row.setSpacing(10) + number = QLabel(f"{index:02d}", button) + number.setObjectName("AiStepNumber") + number.setFixedSize(22, 22) + number.setAlignment(Qt.AlignmentFlag.AlignCenter) + number.setFont(num_font(10, weight=QFont.Weight.Bold)) + row.addWidget(number) + name = QLabel(text, button) + name.setObjectName("AiStepName") + row.addWidget(name, 1) + badge = QLabel("", button) + badge.setObjectName("AiStepBadge") + badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + badge.setMinimumWidth(20) + badge.setFixedHeight(17) + badge.setFont(num_font(10)) + row.addWidget(badge, 0, Qt.AlignmentFlag.AlignVCenter) + self.buttons[key] = button + self.badges[key] = badge + self.numbers[key] = number + self.names[key] = name + nav_layout.addWidget(button) + layout.addWidget(nav) + self.set_current(steps[0][0] if steps else "") + + focus = QFrame(self) + focus.setObjectName("AiPanel") + focus_layout = QVBoxLayout(focus) + focus_layout.setContentsMargins(12, 13, 12, 14) + focus_layout.setSpacing(7) + caption = QLabel("本次关注", focus) + caption.setStyleSheet(f"color: {CONSOLE['faint']}; font-size: 10.2px; letter-spacing: 1.4px;" + " padding-left: 4px;") + focus_layout.addWidget(caption) + self.focus_values: dict[str, QLabel] = {} + for key, text, unit, tone in self.FOCUS_ROWS: + row = QFrame(focus) + row.setObjectName("AiFocusRow") + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(10, 8, 10, 8) + row_layout.setSpacing(8) + name = QLabel(text, row) + name.setStyleSheet(f"color: {CONSOLE['muted']}; font-size: 11.25px;") + row_layout.addWidget(name) + row_layout.addStretch(1) + value = QLabel(f"— {unit}", row) + value.setTextFormat(Qt.TextFormat.PlainText) + value.setFont(num_font(14)) + value.setStyleSheet(f"color: {CONSOLE[tone]};") + row_layout.addWidget(value) + self.focus_values[key] = value + focus_layout.addWidget(row) + self.save_button = QPushButton("保存本次复核", focus) + self.save_button.setObjectName("AiPrimaryAction") + self.save_button.setFixedHeight(34) + self.save_button.clicked.connect(self.save_requested.emit) + focus_layout.addWidget(self.save_button) + layout.addWidget(focus) + layout.addStretch(1) + + BADGE_TONES = {"hot": ("rose_dim", "rose_text"), "warn": ("amber_dim", "amber_text")} + + def set_current(self, key: str) -> None: + """The selected step is a tinted row with a blue index chip, not a solid blue button.""" + + for target, button in self.buttons.items(): + selected = target == key + button.setChecked(selected) + self.numbers[target].setStyleSheet( + f"background: {CONSOLE['accent'] if selected else CONSOLE['raised']};" + f" color: {'#F2F7FF' if selected else CONSOLE['faint']}; border-radius: 6px;") + self.names[target].setStyleSheet( + f"color: {CONSOLE['heading'] if selected else CONSOLE['muted']}; font-size: 12.6px;" + + (" font-weight: 600;" if selected else "")) + self._paint_badge(target) + + def _paint_badge(self, key: str) -> None: + background, colour = self.BADGE_TONES.get(self._tones.get(key, ""), ("raised", "faint")) + self.badges[key].setStyleSheet( + f"background: {CONSOLE[background]}; color: {CONSOLE[colour]};" + " border-radius: 8px; padding: 0 6px;") + + def set_badges(self, counts: Mapping[str, Any], tones: Mapping[str, str] | None = None) -> None: + """Counts, and the severity that decides whether one reads as rose, amber or quiet.""" + + self._tones = dict(tones or {}) + for key, badge in self.badges.items(): + value = counts.get(key) + badge.setText("" if value in (None, "") else str(value)) + badge.setVisible(bool(badge.text())) + self._paint_badge(key) + + def set_focus(self, counts: Mapping[str, Any]) -> None: + for key, _text, unit, _tone in self.FOCUS_ROWS: + value = counts.get(key) + self.focus_values[key].setText(f"{'—' if value is None else value} {unit}") + + +def rail_qss() -> str: + """The rail and agreement styles for the palette that is active right now.""" + + return f""" +QFrame#AiRail {{ background: transparent; border: 0; }} +QFrame#AiFocusRow {{ background: {CONSOLE['surface_2']}; border: 0; border-radius: 7px; }} +QPushButton#AiStep {{ background: transparent; border: 1px solid transparent; border-radius: 7px; + text-align: left; }} +QPushButton#AiStep:hover {{ background: {CONSOLE['surface_2']}; }} +QPushButton#AiStep:checked {{ background: {CONSOLE['selection']}; + border-color: {CONSOLE['selection_line']}; }} +QLabel#AiStepNumber {{ color: {CONSOLE['faint']}; }} +QLabel#AiStepName {{ color: {CONSOLE['muted']}; font-size: 12.6px; }} +QLabel#AiStepBadge {{ color: {CONSOLE['faint']}; background: {CONSOLE['raised']}; + border-radius: 8px; padding: 0 6px; }} +QFrame#AiAgreement {{ background: {CONSOLE['surface']}; border: 1px solid {CONSOLE['line_soft']}; + border-radius: 10px; }} +QLabel#AiAgreementScoreQwen {{ color: {MODEL_TEXT['qwen']}; font-size: 27px; font-weight: 600; }} +QLabel#AiAgreementScoreOpenai {{ color: {MODEL_TEXT['openai']}; font-size: 27px; font-weight: 600; }} +""" diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_glyphs.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_glyphs.py new file mode 100644 index 000000000..00597d166 --- /dev/null +++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_glyphs.py @@ -0,0 +1,254 @@ +"""Painted glyphs for the prescription analysis window. + +The window ships no image assets, so every icon in the design is drawn here with QPainter at the +size it is used. Each glyph is line art on a transparent background; the colour is supplied by the +caller, which keeps a glyph usable on a card, inside a tinted tile, or on the navigation bar. +""" + +from __future__ import annotations + +from PySide6.QtCore import QPointF, QRectF, QSize, Qt +from PySide6.QtGui import ( + QBrush, + QColor, + QIcon, + QLinearGradient, + QPainter, + QPaintEvent, + QPen, + QPixmap, + QPolygonF, +) +from PySide6.QtWidgets import QSizePolicy, QWidget + +from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE + + +def _pen(painter: QPainter, colour: str, width: float) -> QPen: + pen = QPen(QColor(colour)) + pen.setWidthF(width) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + return pen + + +def paint_glyph(painter: QPainter, kind: str, box: QRectF, colour: str) -> None: + """Draw one glyph inside ``box``. Unknown names draw nothing rather than a placeholder.""" + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + x, y, w, h = box.x(), box.y(), box.width(), box.height() + stroke = max(1.2, min(w, h) / 11) + if kind == "bars": + _pen(painter, colour, stroke) + for index, height in enumerate((0.45, 0.75, 0.6)): + left = x + w * (0.22 + index * 0.28) + painter.drawLine(QPointF(left, y + h * 0.82), QPointF(left, y + h * (0.82 - height))) + elif kind == "doc": + _pen(painter, colour, stroke) + painter.drawRoundedRect(QRectF(x + w * 0.22, y + h * 0.12, w * 0.56, h * 0.76), w * 0.08, w * 0.08) + for index in range(2): + top = y + h * (0.38 + index * 0.2) + painter.drawLine(QPointF(x + w * 0.34, top), QPointF(x + w * 0.66, top)) + elif kind == "box": + _pen(painter, colour, stroke) + top, bottom, middle = y + h * 0.2, y + h * 0.8, y + h * 0.5 + left, right = x + w * 0.16, x + w * 0.84 + painter.drawPolygon(QPolygonF([QPointF(x + w * 0.5, top), QPointF(right, middle * 0.75 + top * 0.25), + QPointF(right, bottom - h * 0.12), QPointF(x + w * 0.5, bottom), + QPointF(left, bottom - h * 0.12), QPointF(left, middle * 0.75 + top * 0.25)])) + painter.drawLine(QPointF(x + w * 0.5, y + h * 0.5), QPointF(x + w * 0.5, bottom)) + elif kind == "image": + _pen(painter, colour, stroke) + painter.drawRoundedRect(QRectF(x + w * 0.16, y + h * 0.22, w * 0.68, h * 0.56), w * 0.08, w * 0.08) + painter.drawPolyline(QPolygonF([QPointF(x + w * 0.24, y + h * 0.7), QPointF(x + w * 0.42, y + h * 0.48), + QPointF(x + w * 0.58, y + h * 0.66), QPointF(x + w * 0.68, y + h * 0.56)])) + painter.setBrush(QColor(colour)) + painter.drawEllipse(QPointF(x + w * 0.64, y + h * 0.36), stroke * 0.9, stroke * 0.9) + elif kind == "clock": + _pen(painter, colour, stroke) + painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68)) + centre = QPointF(x + w * 0.5, y + h * 0.5) + painter.drawLine(centre, QPointF(x + w * 0.5, y + h * 0.3)) + painter.drawLine(centre, QPointF(x + w * 0.66, y + h * 0.58)) + elif kind == "trend": + _pen(painter, colour, stroke) + painter.drawPolyline(QPolygonF([QPointF(x + w * 0.18, y + h * 0.68), QPointF(x + w * 0.4, y + h * 0.46), + QPointF(x + w * 0.56, y + h * 0.58), QPointF(x + w * 0.82, y + h * 0.28)])) + painter.drawPolyline(QPolygonF([QPointF(x + w * 0.62, y + h * 0.28), QPointF(x + w * 0.82, y + h * 0.28), + QPointF(x + w * 0.82, y + h * 0.48)])) + elif kind == "bell": + _pen(painter, colour, stroke) + painter.drawPolyline(QPolygonF([ + QPointF(x + w * 0.24, y + h * 0.68), QPointF(x + w * 0.3, y + h * 0.58), + QPointF(x + w * 0.3, y + h * 0.42), QPointF(x + w * 0.5, y + h * 0.2), + QPointF(x + w * 0.7, y + h * 0.42), QPointF(x + w * 0.7, y + h * 0.58), + QPointF(x + w * 0.76, y + h * 0.68), QPointF(x + w * 0.24, y + h * 0.68)])) + painter.drawArc(QRectF(x + w * 0.4, y + h * 0.66, w * 0.2, h * 0.18), 0, -180 * 16) + elif kind == "clipboard": + _pen(painter, colour, stroke) + painter.drawRoundedRect(QRectF(x + w * 0.22, y + h * 0.2, w * 0.56, h * 0.66), w * 0.08, w * 0.08) + painter.drawLine(QPointF(x + w * 0.36, y + h * 0.48), QPointF(x + w * 0.64, y + h * 0.48)) + painter.drawLine(QPointF(x + w * 0.36, y + h * 0.64), QPointF(x + w * 0.56, y + h * 0.64)) + elif kind == "pencil": + _pen(painter, colour, stroke) + painter.drawPolyline(QPolygonF([QPointF(x + w * 0.24, y + h * 0.76), QPointF(x + w * 0.28, y + h * 0.6), + QPointF(x + w * 0.64, y + h * 0.24), QPointF(x + w * 0.78, y + h * 0.38), + QPointF(x + w * 0.42, y + h * 0.74), QPointF(x + w * 0.24, y + h * 0.76)])) + elif kind == "bulb": + _pen(painter, colour, stroke) + painter.drawArc(QRectF(x + w * 0.28, y + h * 0.18, w * 0.44, h * 0.46), 0, 180 * 16) + painter.drawLine(QPointF(x + w * 0.28, y + h * 0.41), QPointF(x + w * 0.38, y + h * 0.62)) + painter.drawLine(QPointF(x + w * 0.72, y + h * 0.41), QPointF(x + w * 0.62, y + h * 0.62)) + painter.drawLine(QPointF(x + w * 0.38, y + h * 0.66), QPointF(x + w * 0.62, y + h * 0.66)) + painter.drawLine(QPointF(x + w * 0.42, y + h * 0.78), QPointF(x + w * 0.58, y + h * 0.78)) + elif kind == "flask": + _pen(painter, colour, stroke) + painter.drawLine(QPointF(x + w * 0.38, y + h * 0.2), QPointF(x + w * 0.62, y + h * 0.2)) + painter.drawPolyline(QPolygonF([QPointF(x + w * 0.44, y + h * 0.2), QPointF(x + w * 0.44, y + h * 0.44), + QPointF(x + w * 0.24, y + h * 0.78), QPointF(x + w * 0.76, y + h * 0.78), + QPointF(x + w * 0.56, y + h * 0.44), QPointF(x + w * 0.56, y + h * 0.2)])) + elif kind == "alert": + _pen(painter, colour, stroke) + painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68)) + painter.drawLine(QPointF(x + w * 0.5, y + h * 0.33), QPointF(x + w * 0.5, y + h * 0.56)) + painter.setBrush(QColor(colour)) + painter.drawEllipse(QPointF(x + w * 0.5, y + h * 0.68), stroke * 0.7, stroke * 0.7) + elif kind == "paperclip": + _pen(painter, colour, stroke) + painter.drawRoundedRect(QRectF(x + w * 0.18, y + h * 0.22, w * 0.64, h * 0.5), w * 0.1, w * 0.1) + painter.drawLine(QPointF(x + w * 0.3, y + h * 0.72), QPointF(x + w * 0.3, y + h * 0.84)) + elif kind == "info": + _pen(painter, colour, stroke) + painter.drawEllipse(QRectF(x + w * 0.16, y + h * 0.16, w * 0.68, h * 0.68)) + painter.drawLine(QPointF(x + w * 0.5, y + h * 0.46), QPointF(x + w * 0.5, y + h * 0.68)) + painter.setBrush(QColor(colour)) + painter.drawEllipse(QPointF(x + w * 0.5, y + h * 0.34), stroke * 0.7, stroke * 0.7) + elif kind == "refresh": + _pen(painter, colour, stroke) + painter.drawArc(QRectF(x + w * 0.2, y + h * 0.2, w * 0.6, h * 0.6), 40 * 16, 280 * 16) + painter.setBrush(QColor(colour)) + painter.drawPolygon(QPolygonF([QPointF(x + w * 0.72, y + h * 0.12), QPointF(x + w * 0.86, y + h * 0.34), + QPointF(x + w * 0.6, y + h * 0.32)])) + elif kind == "save": + _pen(painter, colour, stroke) + painter.drawRoundedRect(QRectF(x + w * 0.2, y + h * 0.2, w * 0.6, h * 0.6), w * 0.08, w * 0.08) + painter.drawLine(QPointF(x + w * 0.36, y + h * 0.2), QPointF(x + w * 0.36, y + h * 0.42)) + painter.drawLine(QPointF(x + w * 0.36, y + h * 0.42), QPointF(x + w * 0.64, y + h * 0.42)) + painter.drawLine(QPointF(x + w * 0.64, y + h * 0.42), QPointF(x + w * 0.64, y + h * 0.2)) + painter.drawRect(QRectF(x + w * 0.36, y + h * 0.56, w * 0.28, h * 0.24)) + elif kind == "chevron": + _pen(painter, colour, stroke) + painter.drawPolyline(QPolygonF([QPointF(x + w * 0.4, y + h * 0.28), QPointF(x + w * 0.62, y + h * 0.5), + QPointF(x + w * 0.4, y + h * 0.72)])) + elif kind == "shield": + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(colour)) + painter.drawPolygon(QPolygonF([QPointF(x + w * 0.5, y + h * 0.16), QPointF(x + w * 0.82, y + h * 0.3), + QPointF(x + w * 0.82, y + h * 0.56), QPointF(x + w * 0.5, y + h * 0.84), + QPointF(x + w * 0.18, y + h * 0.56), QPointF(x + w * 0.18, y + h * 0.3)])) + elif kind == "check": + _pen(painter, colour, stroke * 1.2) + painter.drawPolyline(QPolygonF([QPointF(x + w * 0.32, y + h * 0.52), QPointF(x + w * 0.45, y + h * 0.65), + QPointF(x + w * 0.7, y + h * 0.37)])) + elif kind == "spark": + # The 千问 mark: three crossing strokes forming a six-pointed star. + _pen(painter, colour, stroke * 1.1) + centre = QPointF(x + w * 0.5, y + h * 0.5) + radius = min(w, h) * 0.3 + for angle in (90, 30, -30): + from math import cos, radians, sin + dx, dy = cos(radians(angle)) * radius, -sin(radians(angle)) * radius + painter.drawLine(QPointF(centre.x() - dx, centre.y() - dy), QPointF(centre.x() + dx, centre.y() + dy)) + elif kind == "knot": + # The OpenAI mark, reduced to the interlocking hexagon it is built from. + _pen(painter, colour, stroke) + from math import cos, radians, sin + centre = QPointF(x + w * 0.5, y + h * 0.5) + radius = min(w, h) * 0.3 + points = [QPointF(centre.x() + cos(radians(angle)) * radius, centre.y() + sin(radians(angle)) * radius) + for angle in range(0, 360, 60)] + painter.drawPolygon(QPolygonF(points)) + painter.drawLine(points[0], points[3]) + painter.restore() + + +class Glyph(QWidget): + """A single painted icon at a fixed size.""" + + def __init__(self, kind: str, colour: str, size: int = 18, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.kind, self.colour = kind, colour + self.setFixedSize(size, size) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + + def set_colour(self, colour: str) -> None: + if colour != self.colour: + self.colour = colour + self.update() + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + paint_glyph(painter, self.kind, QRectF(0, 0, self.width(), self.height()), self.colour) + painter.end() + + +class LogoTile(QWidget): + """A rounded tile with a glyph on it: the window mark and the two model marks.""" + + def __init__(self, kind: str, *, start: str, end: str, glyph: str = "#FFFFFF", + size: int = 34, radius: float = 10.0, circle: bool = False, + parent: QWidget | None = None) -> None: + super().__init__(parent) + self.kind, self.start, self.end, self.glyph_colour = kind, start, end, glyph + self.radius, self.circle = radius, circle + self.setFixedSize(size, size) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + box = QRectF(0, 0, self.width(), self.height()) + gradient = QLinearGradient(box.topLeft(), box.bottomRight()) + gradient.setColorAt(0.0, QColor(self.start)) + gradient.setColorAt(1.0, QColor(self.end)) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(gradient)) + if self.circle: + painter.drawEllipse(box) + else: + painter.drawRoundedRect(box, self.radius, self.radius) + paint_glyph(painter, self.kind, box, self.glyph_colour) + painter.end() + + def minimumSizeHint(self) -> QSize: + return QSize(self.width(), self.height()) + + +def window_mark(parent: QWidget | None = None, size: int = 38) -> LogoTile: + tile = LogoTile("check", start=TECH_BLUE["accent"], end=TECH_BLUE["accent_pressed"], + size=size, radius=11, parent=parent) + tile.setAccessibleName("诊断与药方对照") + return tile + + +def model_mark(model_key: str, parent: QWidget | None = None, size: int = 30) -> LogoTile: + if model_key == "openai": + return LogoTile("knot", start=TECH_BLUE["openai_dim"], end=TECH_BLUE["surface_2"], + glyph=TECH_BLUE["openai_text"], size=size, circle=True, parent=parent) + return LogoTile("spark", start=TECH_BLUE["qwen_dim"], end=TECH_BLUE["surface_2"], + glyph=TECH_BLUE["qwen_text"], size=size, radius=9, parent=parent) + + +def glyph_icon(kind: str, colour: str, size: int = 16) -> QIcon: + """The same line art as a QIcon, for buttons that place their own icon and label.""" + + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + paint_glyph(painter, kind, QRectF(0, 0, size, size), colour) + painter.end() + return QIcon(pixmap) diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_labels.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_labels.py index edfa0abd8..dbabd59eb 100644 --- a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_labels.py +++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_labels.py @@ -283,3 +283,61 @@ def value_text(value: Any, field: str, labels: Mapping[str, str], fields: Mappin if field and field not in fields: return system_text(value, labels, fields) return plain_text(value) + + +STATE_LABELS = { + "blank": "尚未开方", "not_generated": "尚无分析记录", "unavailable": "暂无可比结果", + "not_applicable": "尚未开方", "not_started": "尚未分析", "pending": "待分析", + "preparing": "准备资料", "waiting_sources": "等待转写/资料", "retry_wait": "等待重试", "blocked": "需完善资料关联", + "queued": "待分析", "waiting": "等待资料", "waiting_transcript": "等待转写", + "waiting_transcription": "等待转写", "running": "分析中", "processing": "分析中", + "retrying": "重试中", "succeeded": "已完成", "completed": "已完成", "success": "已完成", + "partial": "部分完成", "failed": "需重试", "cancelled": "已取消", "canceled": "已取消", + "stale": "处方已变更", "superseded": "处方已变更", "invalid": "已失效", + "prescription_changed": "处方已变更", "source_updated": "资料已更新", "voided": "处方已作废", "deleted": "处方已删除", "revoked": "权限已撤销", + "current": "当前版本", "valid": "当前有效", "complete": "资料清单完整", + "incomplete": "资料不全", "missing": "资料缺失", "unknown": "未确认", + "needs_patient_link": "需完善患者关联", "patient_unlinked": "需完善患者关联", + "independent_baseline": "独立基线", "baseline": "独立基线", + "latest_context": "最新资料对照", "supplemental": "最新资料对照", + "assisted_revision": "AI 辅助后修订", "ai_assisted": "AI 辅助后修订", + "non_independent": "非独立对照", "auxiliary": "辅助复核", + "comparable": "可比", "not_comparable": "不可比", + "available_for_review": "供医生复核", "insufficient_data": "资料不足,暂不提供候选用药", + "withheld_for_risk": "因风险暂缓候选用药", "viewed": "已查看", "needs_information": "需补充资料", + "not_adopted": "不采纳", "reviewed": "已复核", + "per_dose": "每剂", "per_day": "每日", "matched": "共同药味", "doctor_only": "仅医生方", "candidate_only": "仅模型方", + "insufficient_sample": "样本不足", "descriptive_only": "仅作描述性统计", +} +FIELD_LABELS = { + "summary": "概要", "timeline": "病程", "analysis": "综合分析", "tcm_analysis": "中医辨证", + "diagnosis": "辨证分析", "treatment_advice": "治疗与随访建议", "risk_assessment": "需复核风险", + "evidence_references": "证据来源编号", "missing_information": "待补充资料", "level": "风险等级", "label": "说明", + "risk_warnings": "需复核风险", "risks": "风险", "follow_up": "随访建议", "evidence": "依据", + "sources": "来源", "manifest": "来源清单", "missing": "资料缺口", "status": "状态", + "reason": "原因", "name": "药名", "herb_name": "规范药名", "canonical_name": "规范药名", + "processing": "炮制", "dosage": "剂量", "dose": "剂量", "unit": "单位", "dose_basis": "剂量基准", + "formula_type": "主辅方", "doctor_dosage": "医生剂量", "candidate_dosage": "模型剂量", + "doctor_dose": "医生剂量", "candidate_dose": "模型剂量", "ai_dose": "模型剂量", + "contribution": "匹配贡献", "ratio": "匹配贡献", "match_ratio": "匹配贡献", "match": "匹配情况", + "prescription_name": "候选方名称", "prescription_type": "剂型", "herbs": "药味", + "dose_count": "剂数", "usage_days": "疗程(天)", "times_per_day": "每日服次", + "usage_instruction": "服法", "usage_time": "服药时间", "usage_way": "给药途径", + "rationale": "方义与依据", "usage_differences": "用法、疗程与风险差异", "normalization": "规范化记录", + "algorithm_version": "算法版本", "dictionary_version": "药材字典版本", "model_version": "模型版本", + "prompt_version": "提示词版本", "doctor_count": "医生药项数", "candidate_count": "候选药项数", + "matched_count": "共同药项数", "coverage": "模型资料覆盖", "source_summary": "来源汇总", + "cutoff_at": "资料截止时间", "generated_at": "报告生成时间", "comment": "复核意见", + "match_type": "增减药项", "administration_route": "给药途径", "group": "用药组", + "delivered": "已送达", "unreadable": "不可读", "unsupported": "不支持", "parsed": "已解析", + "diagnosis_count": "病历数", "prescription_count": "历史处方数", "chat_count": "聊天记录数", + "daily_record_count": "日常记录数", "transcript_count": "转写数", "attachment_count": "附件数", + "files": "附件处理清单", "source_ids": "已读取来源编号", "source_id": "来源编号", "file_id": "附件编号", + "complete": "资料清单完整", "source_complete": "文字来源齐全", "transmitted": "附件已送达", "critical": "关键资料缺口", + "baseline_eligible": "独立基线统计资格", "baseline_exclusion_reasons": "基线排除原因", "instructions": "特殊煎服说明", + "versions": "版本信息", "strata": "按版本分层", "count": "样本数", "mean": "均值", "median": "中位数", + "distribution": "一致度分布", "sample_status": "样本说明", +} +STATE_LABELS.update(SYSTEM_LABELS) +FIELD_LABELS.update(EXTRA_FIELD_LABELS) + diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_pages.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_pages.py new file mode 100644 index 000000000..fea4d8928 --- /dev/null +++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_pages.py @@ -0,0 +1,1575 @@ +"""Composed pages for the prescription analysis window: per-herb, sources, progress, history. + +Each page renders only what the saved batch carries. Missing values stay visibly absent, and no +page issues a request of its own: the dialog owns all polling and passes the batch down. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QColor, QFont, QPainter +from PySide6.QtWidgets import ( + QAbstractItemView, + QFrame, + QGridLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from .issued_prescription_ai_charts import TrendBars, WaffleCoverage +from .issued_prescription_ai_labels import FIELD_LABELS, STATE_LABELS, system_text +from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE +from .issued_prescription_ai_theme import num_font, on_theme_changed + +MODEL_NAMES = {"qwen": "千问", "openai": "OpenAI"} +# Upstream stage keys arrive as ``text:0`` / ``files:1``; the index is the batch within the stage. +STAGE_NAMES = {"text": "文字资料", "files": "附件读取", "reduce": "归并", "final": "生成候选与报告", + "repair": "格式修复", "names": "药名核对", "insist": "补充追问"} +ACCENTS = {"qwen": TECH_BLUE["qwen"], "openai": TECH_BLUE["openai"]} + + +@on_theme_changed +def _rebuild_accents() -> None: + ACCENTS.update({"qwen": TECH_BLUE["qwen"], "openai": TECH_BLUE["openai"]}) + + +def _mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _number(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed + + +def _text(value: Any) -> str: + return str(value).strip() if isinstance(value, (str, int, float)) and not isinstance(value, bool) else "" + + +def _label(value: Any) -> str: + """Shared translator: the same maps the report panels use, so codes never leak to the UI.""" + + return system_text(value, STATE_LABELS, FIELD_LABELS) if value not in (None, "") else "" + + +def _stage_text(value: Any) -> str: + text = _text(value) + head, _, tail = text.partition(":") + name = STAGE_NAMES.get(head) + if not name: + return text or "—" + return f"{name} {int(tail) + 1}" if tail.isdigit() else name + + +def _fit_rows(table: QTableWidget) -> None: + """A table is content, not a scroll area: give it exactly the height its rows need. + + Boxed into whatever space is left over, a table shows one row at a time and cuts its own + header; laid out in full it simply makes the page longer, and the page already scrolls. + """ + + table.resizeRowsToContents() + rows = sum(table.rowHeight(index) for index in range(table.rowCount())) + header = 0 if table.horizontalHeader().isHidden() else table.horizontalHeader().height() + table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + table.setFixedHeight(header + max(rows, 31) + 4) + + +def _coverage_text(value: Any) -> str: + """``partial`` means part of the material is missing here, not that a task half finished.""" + + return {"partial": "部分资料缺失"}.get(_text(value)) or _label(value) or "—" + + +def _dose(value: Any) -> float | None: + """A dose may arrive as a number or inside the normalized row object.""" + + if isinstance(value, Mapping): + return _number(_mapping(value).get("dosage")) + return _number(value) + + +def _title(text: str, hint: str = "", parent: QWidget | None = None) -> QWidget: + holder = QWidget(parent) + layout = QHBoxLayout(holder) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + title = QLabel(text, holder) + title.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 13.5px; font-weight: 600;") + layout.addWidget(title) + if hint: + note = QLabel(hint, holder) + note.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 10.65px;") + note.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + note.setMinimumWidth(0) + note.setToolTip(hint) + layout.addWidget(note, 1) + else: + layout.addStretch(1) + return holder + + +def _card(parent: QWidget | None = None) -> tuple[QFrame, QVBoxLayout]: + frame = QFrame(parent) + frame.setObjectName("AiMetricCard") + layout = QVBoxLayout(frame) + layout.setContentsMargins(18, 15, 18, 18) + layout.setSpacing(8) + return frame, layout + + +def _table(headers: list[str]) -> QTableWidget: + table = QTableWidget(0, len(headers)) + table.setHorizontalHeaderLabels(headers) + table.verticalHeader().hide() + table.setShowGrid(False) + table.setAlternatingRowColors(True) + table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + table.horizontalHeader().setStretchLastSection(False) + table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + table.setStyleSheet( + f"QTableWidget {{ border: 0; background: transparent; }}" + f"QHeaderView::section {{ background: {TECH_BLUE['surface_2']}; color: {TECH_BLUE['faint']}; border: 0;" + f" border-bottom: 1px solid {TECH_BLUE['line_soft']}; padding: 10px 16px; font-size: 10px; }}" + f"QTableWidget::item {{ padding: 8px 16px; font-size: 12px; }}") + return table + + +def _item(text: str, *, align_right: bool = False, colour: str | None = None, tooltip: str = "") -> QTableWidgetItem: + item = QTableWidgetItem(text) + if align_right: + item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + if colour: + item.setForeground(Qt.GlobalColor.darkGray if colour == "muted" else Qt.GlobalColor.black) + if tooltip: + item.setToolTip(tooltip) + return item + + +class ModelDoseCell(QWidget): + """One model's cell in the matrix: the dose, the contribution bar, and its value.""" + + BAR_WIDTH = 54 + BAR_HEIGHT = 5 + + def __init__(self, dose: str, contribution: float | None, accent: str, + parent: QWidget | None = None) -> None: + super().__init__(parent) + self.dose, self.contribution, self.accent = dose, contribution, accent + self.setFixedHeight(26) + described = dose if contribution is None else f"{dose},贡献 {contribution:.2f}" + self.setAccessibleDescription(described) + self.setToolTip("未收录或不可比,不计入分子" if contribution is None else + f"贡献 {contribution:.2f} = min(医生剂量, 候选剂量) ÷ max(医生剂量, 候选剂量)") + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setFont(num_font(12)) + metrics = painter.fontMetrics() + missing = self.dose == "—" + painter.setPen(QColor(TECH_BLUE["faint"] if missing else TECH_BLUE["text"])) + dose_width = max(46, metrics.horizontalAdvance(self.dose) + 6) + painter.drawText(QRectF(0, 0, dose_width, self.height()), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self.dose) + if self.contribution is None: + painter.end() + return + left = dose_width + 8 + top = (self.height() - self.BAR_HEIGHT) / 2 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(TECH_BLUE["raised"])) + painter.drawRoundedRect(QRectF(left, top, self.BAR_WIDTH, self.BAR_HEIGHT), 2.5, 2.5) + painter.setBrush(QColor(self.accent)) + painter.drawRoundedRect(QRectF(left, top, self.BAR_WIDTH * min(1.0, self.contribution), + self.BAR_HEIGHT), 2.5, 2.5) + painter.setPen(QColor(TECH_BLUE["text"])) + painter.drawText(QRectF(left + self.BAR_WIDTH + 8, 0, 44, self.height()), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + f"{self.contribution:.2f}") + painter.end() + + +VERDICTS = { + "both": ("两模型均收录", "selection", "accent_text"), + "one": ("仅单模型收录", "qwen_dim", "qwen_text"), + "diff": ("剂量分歧", "amber_dim", "amber_text"), + "doc": ("仅医方使用", "raised", "muted"), + "lack": ("两模型均未收录", "rose_dim", "rose_text"), +} + + +class ContributionCell(QWidget): + """A contribution as a short bar plus its exact value; an absent value stays a dash.""" + + def __init__(self, value: float | None, accent: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.value, self.accent = value, accent + self.setFixedHeight(22) + self.setAccessibleDescription("—" if value is None else f"{value:.2f}") + self.setToolTip("未收录或不可比,不计入分子" if value is None else + f"贡献 {value:.2f} = min(医生剂量, 候选剂量) ÷ max(医生剂量, 候选剂量)") + self.setToolTip("未收录或不可比,不计入分子" if value is None else + f"贡献 {value:.2f} = min(医生剂量, 候选剂量) ÷ max(医生剂量, 候选剂量)") + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setFont(self.font()) + text = "—" if self.value is None else f"{self.value:.2f}" + metrics = painter.fontMetrics() + text_width = metrics.horizontalAdvance("0.00") + 6 + bar_width = max(0, self.width() - text_width - 8) + if self.value is not None and bar_width > 12: + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(f"{TECH_BLUE['raised']}")) + painter.drawRoundedRect(QRectF(0, 8, bar_width, 6), 3, 3) + painter.setBrush(QColor(self.accent)) + painter.drawRoundedRect(QRectF(0, 8, bar_width * min(1.0, self.value), 6), 3, 3) + painter.setPen(QColor(TECH_BLUE["muted"] if self.value is None else TECH_BLUE["heading"])) + painter.drawText(QRectF(self.width() - text_width, 0, text_width, self.height()), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, text) + painter.end() + + +class WrappedLabel(QLabel): + """A wrapping label that claims the height its wrapped text actually needs. + + A plain ``QLabel`` only ever asks for one line, so a layout hands it one line and the rest is + clipped; this asks again for the width it ended up with. + """ + + def __init__(self, text: str = "", parent: QWidget | None = None) -> None: + super().__init__(text, parent) + self.setWordWrap(True) + policy = self.sizePolicy() + policy.setHeightForWidth(True) + policy.setVerticalPolicy(QSizePolicy.Policy.Minimum) + self.setSizePolicy(policy) + + def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + super().resizeEvent(event) + needed = self.heightForWidth(self.width()) + if needed > 0 and needed != self.minimumHeight(): + self.setMinimumHeight(needed) + + +class PrescriptionCard(QFrame): + """One prescription as it was saved: name, dose, and the usage line underneath.""" + + def __init__(self, title: str, accent: str | None, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AiMetricCard") + if accent: + self.setStyleSheet(f"QFrame#AiMetricCard {{ border-left: 4px solid {accent}; }}") + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 12, 18, 12) + layout.setSpacing(6) + head = QHBoxLayout() + head.setSpacing(8) + self.title = QLabel(title, self) + self.title.setStyleSheet( + f"background: {'#EAF2FF' if accent else '#EEF3FB'}; color: {accent or '#3B5675'};" + " border-radius: 9px; padding: 2px 9px; font-size: 11px;") + head.addWidget(self.title) + self.summary = QLabel("—", self) + self.summary.setTextFormat(Qt.TextFormat.PlainText) + self.summary.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 13px; font-weight: 600;") + head.addWidget(self.summary) + head.addStretch(1) + self.state = QLabel("", self) + self.state.setTextFormat(Qt.TextFormat.PlainText) + self.state.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11px;") + head.addWidget(self.state) + layout.addLayout(head) + self.herbs = QLabel("尚未保存药味", self) + self.herbs.setTextFormat(Qt.TextFormat.RichText) + self.herbs.setWordWrap(False) + self.herbs.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + self.herbs.setAlignment(Qt.AlignmentFlag.AlignTop) + layout.addWidget(self.herbs) + layout.addStretch(1) + self.usage = WrappedLabel("", self) + self.usage.setTextFormat(Qt.TextFormat.PlainText) + self.usage.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;") + layout.addWidget(self.usage) + + MAX_ROWS = 5 + + def set_prescription(self, herbs: list[Any], summary: str, usage: str, state: str = "") -> None: + rows = [] + for herb in herbs[:self.MAX_ROWS]: + value = _mapping(herb) + dose = _number(value.get("dosage")) + unit = _text(value.get("unit")) or "g" + amount = "—" if dose is None else f"{dose:g} {unit}" + rows.append( + f"" + f"") + if len(herbs) > self.MAX_ROWS: + rows.append(f"") + self.herbs.setText(f"
{_text(value.get('name')) or '药名未保存'}{amount}
" + f"另有 {len(herbs) - self.MAX_ROWS} 味,见下方逐味对照
{''.join(rows)}
" if rows else "尚未保存药味") + self.summary.setText(summary) + self.usage.setText(usage) + self.usage.setToolTip(usage) + self.state.setText(state) + + +class CandidatesPage(QWidget): + """The three prescriptions side by side, then every herb with the contribution it earned.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._rows: list[dict[str, Any]] = [] + self._filter = "all" + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 12, 14, 10) + layout.setSpacing(10) + + cards = QHBoxLayout() + cards.setSpacing(12) + self.cards = { + "doctor": PrescriptionCard("医生原方", None, self), + "qwen": PrescriptionCard(f"{MODEL_NAMES['qwen']}候选", ACCENTS["qwen"], self), + "openai": PrescriptionCard(f"{MODEL_NAMES['openai']} 候选", ACCENTS["openai"], self), + } + for card in self.cards.values(): + card.setMinimumHeight(200) + cards.addWidget(card, 1) + layout.addLayout(cards) + + body, body_layout = _card(self) + head = _title( + "逐味对照与贡献", "一致度 = 2 × Σ 各共同药味贡献 ÷(医生药味数 + 候选药味数)", body) + legend = QLabel( + f" {MODEL_NAMES['qwen']}贡献度" + f"   {MODEL_NAMES['openai']} 贡献度", head) + legend.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 10.65px;") + head.layout().addWidget(legend) + body_layout.addWidget(head) + filters = QHBoxLayout() + filters.setSpacing(8) + self.filter_buttons: dict[str, QPushButton] = {} + for key, text in (("all", "全部"), ("shared", "两模型均收录"), ("single", "仅单模型收录"), + ("doctor", "仅医方收录"), ("different", "剂量分歧 ≥5 克")): + button = QPushButton(text, body) + button.setCheckable(True) + button.setChecked(key == "all") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setStyleSheet( + f"QPushButton {{ border: 1px solid {TECH_BLUE['line']}; border-radius: 8px; padding: 6px 13px;" + f" color: {TECH_BLUE['muted']}; background: {TECH_BLUE['surface_2']}; font-size: 11.1px; }}" + f"QPushButton:checked {{ background: {TECH_BLUE['accent']}; border-color: {TECH_BLUE['accent']};" + f" color: {TECH_BLUE['surface']}; font-weight: 600; }}") + button.clicked.connect(lambda _checked=False, target=key: self._choose(target)) + self.filter_buttons[key] = button + filters.addWidget(button) + filters.addStretch(1) + self.search = QLineEdit(body) + self.search.setPlaceholderText("搜索药名…") + self.search.setFixedWidth(190) + self.search.textChanged.connect(lambda _text: self._render()) + filters.addWidget(self.search) + body_layout.addLayout(filters) + self.table = _table(["药味", f"{MODEL_NAMES['qwen']}(克/剂 · 贡献度)", + f"{MODEL_NAMES['openai']}(克/剂 · 贡献度)", "医方原方", "结论"]) + self.table.verticalHeader().setDefaultSectionSize(38) + header = self.table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + for column, width in ((1, 190), (2, 190), (3, 110), (4, 150)): + header.setSectionResizeMode(column, QHeaderView.ResizeMode.Fixed) + self.table.setColumnWidth(column, width) + self.table.setStyleSheet( + f"QTableWidget {{ border: 0; background: transparent; color: {TECH_BLUE['text']}; }}" + f"QHeaderView::section {{ background: {TECH_BLUE['surface_2']}; color: {TECH_BLUE['faint']};" + f" border: 0; border-bottom: 1px solid {TECH_BLUE['line_soft']}; padding: 10px 14px;" + " font-size: 10px; }" + f"QTableWidget::item {{ border-bottom: 1px solid {TECH_BLUE['line_soft']};" + " padding: 9px 14px; }") + body_layout.addWidget(self.table, 1) + layout.addWidget(body, 1) + + self.usage = QWidget(self) + usage_row = QHBoxLayout(self.usage) + usage_row.setContentsMargins(0, 0, 0, 0) + usage_row.setSpacing(12) + self.usage_values: dict[str, tuple[QLabel, QLabel]] = {} + for key, caption in (("usage", "服法"), ("course", "疗程"), ("form", "剂型"), ("aux", "辅方")): + cell = QFrame(self.usage) + cell.setObjectName("AiUsageCell") + cell.setStyleSheet( + f"QFrame#AiUsageCell {{ background: {TECH_BLUE['surface_2']}; border: 1px solid {TECH_BLUE['line']};" + " border-radius: 12px; }}") + box = QVBoxLayout(cell) + box.setContentsMargins(12, 8, 12, 8) + box.setSpacing(1) + head = QLabel(caption, cell) + head.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 10.5px;") + box.addWidget(head) + doctor = QLabel("医生:—", cell) + doctor.setTextFormat(Qt.TextFormat.PlainText) + doctor.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 12.5px; font-weight: 600;") + box.addWidget(doctor) + model = QLabel("模型:—", cell) + model.setTextFormat(Qt.TextFormat.PlainText) + model.setStyleSheet(f"color: {TECH_BLUE['amber_text']}; font-size: 11px;") + box.addWidget(model) + self.usage_values[key] = (doctor, model) + usage_row.addWidget(cell, 1) + layout.addWidget(self.usage) + self.footnote = QLabel( + "贡献 = min(医生剂量, 候选剂量) ÷ max(医生剂量, 候选剂量);未收录与不可比药味不进分子,也不被剔除出分母。", self) + self.footnote.setWordWrap(True) + self.footnote.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;") + layout.addWidget(self.footnote) + + def row_count(self) -> int: + """How many herbs the matrix holds, for the rail's badge.""" + + return len(self._rows) + + def _choose(self, key: str) -> None: + self._filter = key + for name, button in self.filter_buttons.items(): + button.setChecked(name == key) + self._render() + + def set_batch(self, batch: Mapping[str, Any] | None) -> None: + data = _mapping(batch) + models = _mapping(data.get("models")) + merged: dict[str, dict[str, Any]] = {} + for key in ("qwen", "openai"): + comparison = _mapping(_mapping(models.get(key)).get("comparison")) + rows = comparison.get("rows") + if not isinstance(rows, list): + continue + for row in rows: + value = _mapping(row) + name = _text(value.get("name")) + if not name: + continue + entry = merged.setdefault(name, {"name": name, "doctor": None, "unit": "", "qwen": None, + "openai": None, "qwen_contribution": None, + "openai_contribution": None, "processing": ""}) + doctor = _dose(value.get("doctor_dosage")) + if doctor is None: + doctor = _dose(value.get("doctor")) + if doctor is not None: + entry["doctor"] = doctor + candidate = _dose(value.get("candidate_dosage")) + if candidate is None: + candidate = _dose(value.get("candidate")) + entry[key] = candidate + entry[f"{key}_contribution"] = _number(value.get("contribution")) + entry["unit"] = entry["unit"] or _text(value.get("unit")) + entry["processing"] = entry["processing"] or _text(value.get("processing")) + self._rows = sorted(merged.values(), key=lambda row: (row["doctor"] is None, -(row["doctor"] or 0))) + self._render_cards(data) + self._render() + + def _render_cards(self, data: Mapping[str, Any]) -> None: + prescription = _mapping(_mapping(data.get("doctor_snapshot")).get("prescription")) + herbs = prescription.get("herbs") if isinstance(prescription.get("herbs"), list) else [] + form = _text(prescription.get("prescription_type")) + doses = _text(prescription.get("dose_count")) + self.cards["doctor"].set_prescription( + list(herbs), " · ".join(part for part in (f"{len(herbs)} 味" if herbs else "", form) if part) or "未保存原方", + " · ".join(part for part in (_text(prescription.get("usage_instruction")), + f"{doses} 剂" if doses else "") if part) or "用法未保存") + models = _mapping(data.get("models")) + for key in ("qwen", "openai"): + model = _mapping(models.get(key)) + candidate = _mapping(model.get("candidate")) + model_herbs = candidate.get("herbs") if isinstance(candidate.get("herbs"), list) else [] + usage = " · ".join(part for part in ( + f"每日 {_text(candidate.get('times_per_day'))} 次" if _text(candidate.get("times_per_day")) else "", + f"{_text(candidate.get('usage_days'))} 天" if _text(candidate.get("usage_days")) else "", + _text(candidate.get("rationale"))) if part) + warnings = candidate.get("risk_warnings") + if isinstance(warnings, list) and warnings: + usage = (usage + "\n" if usage else "") + "风险提示:" + _text(warnings[0]) + comparison_status = _mapping(model.get("comparison")).get("status") + self.cards[key].set_prescription( + list(model_herbs), + " · ".join(part for part in (f"{len(model_herbs)} 味" if model_herbs else "", + _text(candidate.get("prescription_type"))) if part) or "尚无候选方", + usage or "服法未保存", + _label(comparison_status) or "") + self._render_usage(prescription, models) + + def _render_usage(self, prescription: Mapping[str, Any], models: Mapping[str, Any]) -> None: + """Four facts the score never covers: 服法、疗程、剂型、辅方 — doctor first, models after.""" + + candidates = [_mapping(_mapping(models.get(key)).get("candidate")) for key in ("qwen", "openai")] + candidates = [candidate for candidate in candidates if candidate] + + def agreed(values: list[str]) -> str: + unique = [value for value in dict.fromkeys(values) if value] + if not unique: + return "—" + return unique[0] if len(unique) == 1 else " / ".join(unique) + + herbs = prescription.get("herbs") if isinstance(prescription.get("herbs"), list) else [] + auxiliary = [_text(_mapping(herb).get("name")) for herb in herbs + if _text(_mapping(herb).get("formula_type")) in {"辅方", "aux"}] + doctor_days = _text(prescription.get("usage_days")) + model_days = agreed([f"{_text(candidate.get('usage_days'))} 天" + for candidate in candidates if _text(candidate.get("usage_days"))]) + model_usage = agreed([f"每日 {_text(candidate.get('times_per_day'))} 次" + for candidate in candidates if _text(candidate.get("times_per_day"))]) + doctor_form = _text(prescription.get("prescription_type")) + model_form = agreed([_text(candidate.get("prescription_type")) for candidate in candidates]) + values = { + "usage": (_text(prescription.get("usage_instruction")) or "未记录", model_usage), + "course": (f"{doctor_days} 天" if doctor_days else "未记录", model_days), + "form": (doctor_form or "未记录", model_form), + "aux": ("、".join(auxiliary) if auxiliary else "未记录", "未给出辅方" if candidates else "—"), + } + for key, (doctor_value, model_value) in values.items(): + doctor_label, model_label = self.usage_values[key] + doctor_label.setText(f"医生:{doctor_value}") + model_label.setText(f"模型:{model_value}") + + def _difference(self, row: Mapping[str, Any]) -> bool: + """A dose difference needs two real doses: a missing herb is a coverage gap, not a delta.""" + + if row.get("doctor") is None: + return False + return any(row.get(key) is not None and abs(row[key] - row["doctor"]) > 1e-9 + for key in ("qwen", "openai")) + + @staticmethod + def _verdict(row: Mapping[str, Any]) -> str: + """Which of the design's five conclusions this herb falls under.""" + + models = [key for key in ("qwen", "openai") if row.get(key) is not None] + if not models: + return "doc" if row.get("doctor") is not None else "lack" + if row.get("doctor") is not None and len(models) == 2: + gaps = [abs(row[key] - row["doctor"]) for key in models] + return "diff" if max(gaps) >= 5 else "both" + if len(models) == 2: + return "both" + return "one" + + def _keep(self, row: Mapping[str, Any]) -> bool: + verdict = self._verdict(row) + if self._filter == "shared": + return verdict in {"both", "diff"} + if self._filter == "single": + return verdict == "one" + if self._filter == "doctor": + return verdict in {"doc", "lack"} + if self._filter == "different": + return verdict == "diff" + return True + + def _render(self) -> None: + verdicts = [self._verdict(row) for row in self._rows] + counts = {"all": len(self._rows), + "shared": sum(1 for verdict in verdicts if verdict in {"both", "diff"}), + "single": verdicts.count("one"), + "doctor": sum(1 for verdict in verdicts if verdict in {"doc", "lack"}), + "different": verdicts.count("diff")} + for key, button in self.filter_buttons.items(): + base = {"all": "全部", "shared": "两模型均收录", "single": "仅单模型收录", + "doctor": "仅医方收录", "different": "剂量分歧 ≥5 克"}[key] + button.setText(f"{base} {counts[key]}") + needle = self.search.text().strip() + rows = [row for row in self._rows if (not needle or needle in row["name"]) and self._keep(row)] + self.table.setRowCount(len(rows)) + for index, row in enumerate(rows): + unit = row["unit"] or "g" + name = _item(row["name"]) + if row["processing"]: + name.setToolTip(row["processing"]) + name.setText(f"{row['name']}\n{row['processing']}") + self.table.setItem(index, 0, name) + for column, key in ((1, "qwen"), (2, "openai")): + value = row.get(key) + dose = "—" if value is None else f"{value:g} g" if unit == "g" else f"{value:g} {unit}" + cell = ModelDoseCell(dose, row.get(f"{key}_contribution"), ACCENTS[key], self.table) + self.table.setItem(index, column, _item("")) + self.table.setCellWidget(index, column, cell) + doctor = row.get("doctor") + self.table.setItem(index, 3, _item("未收录" if doctor is None else f"{doctor:g} {unit}")) + self.table.setCellWidget(index, 4, self._verdict_cell(row)) + _fit_rows(self.table) + + def _verdict_cell(self, row: Mapping[str, Any]) -> QWidget: + """The conclusion badge, in the design's five states.""" + + key = self._verdict(row) + text, background, colour = VERDICTS[key] + if key == "one": + owner = MODEL_NAMES["qwen"] if row.get("qwen") is not None else MODEL_NAMES["openai"] + text = f"仅 {owner} 收录" + colour = "qwen_text" if row.get("qwen") is not None else "openai_text" + background = "qwen_dim" if row.get("qwen") is not None else "openai_dim" + if key == "diff": + gaps = [abs(row[model] - row["doctor"]) for model in ("qwen", "openai") + if row.get(model) is not None and row.get("doctor") is not None] + text = f"剂量分歧 {max(gaps):g} {row['unit'] or 'g'}" if gaps else text + holder = QWidget(self.table) + layout = QHBoxLayout(holder) + layout.setContentsMargins(14, 0, 14, 0) + layout.setSpacing(0) + pill = QLabel(text, holder) + pill.setTextFormat(Qt.TextFormat.PlainText) + if key == "doc": + # The design leaves this one unpilled: it states a fact, not something to decide. + pill.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 10px;") + else: + pill.setStyleSheet( + f"background: {TECH_BLUE[background]}; color: {TECH_BLUE[colour]};" + f" border: 1px solid {TECH_BLUE[colour]}; border-radius: 9px; padding: 2px 8px;" + " font-size: 10px;") + layout.addWidget(pill) + layout.addStretch(1) + return holder + + def _explain(self, row: Mapping[str, Any]) -> str: + if row.get("doctor") is None: + owners = [MODEL_NAMES[key] for key in ("qwen", "openai") if row.get(key) is not None] + return "、".join(owners) + " 新增" if owners else "无剂量记录" + missing = [MODEL_NAMES[key] for key in ("qwen", "openai") if row.get(key) is None] + if len(missing) == 2: + return "仅医生使用" + if missing: + return f"{missing[0]} 未收录" + gaps = {key: abs(row[key] - row["doctor"]) for key in ("qwen", "openai")} + largest = max(gaps.values()) + unit = row["unit"] or "g" + return "三方剂量一致" if largest < 1e-9 else f"剂量差 {largest:g} {unit}" + + +class SourceBar(QWidget): + """One source type as a proportional bar; a type with no records still shows its zero.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._fraction = 0.0 + self._colour = TECH_BLUE["accent"] + self.setFixedHeight(9) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + def set_fraction(self, fraction: float, colour: str) -> None: + self._fraction, self._colour = max(0.0, min(1.0, fraction)), colour + self.update() + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(f"{TECH_BLUE['raised']}")) + painter.drawRoundedRect(QRectF(0, 0, self.width(), self.height()), 4, 4) + if self._fraction > 0: + painter.setBrush(QColor(self._colour)) + painter.drawRoundedRect(QRectF(0, 0, max(4.0, self.width() * self._fraction), self.height()), 4, 4) + painter.end() + + +SOURCE_ROWS = (("video_calls_count", "问诊通话", "#155BCC"), ("blood_glucose_pressure_count", "监测记录", "#4A8BEE"), + ("doctor_notes_count", "医生备注", "#7EAEF3"), ("prescriptions_count", "历史处方", "#A9CBF7"), + ("diagnoses_count", "病历", "#CFE1FA"), ("chat_messages_count", "聊天归档", "#CBD5E1"), + ("tencent_im_count", "腾讯 IM", "#CBD5E1"), ("wechat_messages_count", "企微聊天", "#CBD5E1")) + + +ATTACHMENT_TYPES = {"image": "图片", "document": "文档", "audio": "音频", "video": "视频"} +ATTACHMENT_STATES = {"processed": "已读取", "restricted": "存储授权受限", "unsupported": "格式不支持", + "unreadable": "模型未能读出内容", "pending": "未送达模型"} + + +class AttachmentTile(QFrame): + """One attachment: its type, its short id, and whether the models actually read it.""" + + def __init__(self, entry: Mapping[str, Any], index: int, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AiAttachmentTile") + status = _text(entry.get("status")) or "pending" + read = status == "processed" + tone = (TECH_BLUE["qwen_dim"], TECH_BLUE["qwen_text"]) if read else ( + TECH_BLUE["amber_dim"], TECH_BLUE["amber_text"]) + self.setStyleSheet( + f"QFrame#AiAttachmentTile {{ background: {tone[0]}; border: 1px solid {tone[1]};" + " border-radius: 8px; }") + self.setFixedSize(72, 54) + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 5, 6, 5) + layout.setSpacing(1) + kind = ATTACHMENT_TYPES.get(_text(entry.get("type")), "附件") + title = QLabel(f"{kind} {index}", self) + title.setTextFormat(Qt.TextFormat.PlainText) + title.setStyleSheet(f"color: {tone[1]}; font-size: 11.5px; font-weight: 600;") + layout.addWidget(title) + identifier = _text(entry.get("file_id")) + short = identifier[-4:] if len(identifier) > 4 else identifier + caption = QLabel(f"#{short}" if short else "—", self) + caption.setTextFormat(Qt.TextFormat.PlainText) + caption.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 10.5px;") + layout.addWidget(caption) + reason = _label(entry.get("reason")) or _text(entry.get("reason")) + state = ATTACHMENT_STATES.get(status, status) + self.setToolTip("\n".join(part for part in ( + f"{kind} · 编号 {identifier or '未记录'}", f"状态:{state}", + f"原因:{reason}" if reason else "", + "版本已核验" if entry.get("version_verified") else "版本未核验") if part)) + self.setAccessibleName(f"{kind} {index}") + self.setAccessibleDescription(f"{state}") + + +class SourcesPage(QWidget): + """What the batch froze, what the models actually read, and what is still missing.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 12, 14, 10) + layout.setSpacing(12) + + left, left_layout = _card(self) + left.setMinimumWidth(280) + self.composition_title = _title("资料构成", "等待资料汇总", left) + left_layout.addWidget(self.composition_title) + self.composition_rows = QWidget(left) + self.composition_layout = QVBoxLayout(self.composition_rows) + self.composition_layout.setContentsMargins(0, 4, 0, 0) + self.composition_layout.setSpacing(6) + left_layout.addWidget(self.composition_rows) + left_layout.addWidget(_title("读取范围", parent=left)) + self.meta = QLabel("—", left) + self.meta.setWordWrap(True) + self.meta.setTextFormat(Qt.TextFormat.PlainText) + self.meta.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px; line-height: 180%;") + left_layout.addWidget(self.meta) + left_layout.addStretch(1) + layout.addWidget(left, 3) + + middle, middle_layout = _card(self) + middle_layout.addWidget(_title("附件读取情况", "模型逐个独立读取", parent=middle)) + self.waffle = WaffleCoverage(middle, columns=12) + middle_layout.addWidget(self.waffle) + self.tiles = QWidget(middle) + self.tile_grid = QGridLayout(self.tiles) + self.tile_grid.setContentsMargins(0, 2, 0, 2) + self.tile_grid.setSpacing(6) + middle_layout.addWidget(self.tiles) + self.attachment_note = QLabel("附件情况未确认", middle) + self.attachment_note.setWordWrap(True) + self.attachment_note.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px;") + middle_layout.addWidget(self.attachment_note) + self.attachment_legend = QLabel("", middle) + self.attachment_legend.setTextFormat(Qt.TextFormat.RichText) + self.attachment_legend.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;") + middle_layout.addWidget(self.attachment_legend) + middle_layout.addWidget(_title("模型各自读取到的量", "同一批资料,两个模型分别送达", parent=middle)) + self.per_model = _table(["模型", "已读附件", "受限或不支持", "读取比例"]) + middle_layout.addWidget(self.per_model) + middle_layout.addStretch(1) + layout.addWidget(middle, 4) + + right, right_layout = _card(self) + self.gap_title = _title("缺口清单", "等待报告", right) + right_layout.addWidget(self.gap_title) + self.gaps = _table(["缺口类型", "严重度", "条数"]) + right_layout.addWidget(self.gaps) + # Nothing here expands, so without this the card would centre its contents. + right_layout.addStretch(1) + self.gap_note = WrappedLabel( + "缺口不代表资料不存在,只表示本批次未能核验或未能读取。补齐后可用“重新分析”生成新批次,旧报告保留。", right) + self.gap_note.setStyleSheet( + f"background: {TECH_BLUE['amber_dim']}; border: 1px solid {TECH_BLUE['amber']};" + f" border-radius: 7px; padding: 9px 12px; color: {TECH_BLUE['amber_text']};" + " font-size: 10.8px; line-height: 170%;") + right_layout.addWidget(self.gap_note) + layout.addWidget(right, 4) + + def set_batch(self, batch: Mapping[str, Any] | None) -> None: + data = _mapping(batch) + summary = _mapping(data.get("source_summary")) + rows = [(name, int(value), colour) for key, name, colour in SOURCE_ROWS + if (value := _number(summary.get(key))) is not None] + largest = max((value for _name, value, _colour in rows), default=0) + while self.composition_layout.count(): + item = self.composition_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + for name, value, colour in rows: + self.composition_layout.addWidget(self._source_row(name, value, colour, largest)) + total = _number(summary.get("source_record_count")) + counted = sum(value for _name, value, _colour in rows) + self.composition_title.findChildren(QLabel)[-1].setText( + f"{int(total)} 条" if total is not None else (f"共 {counted} 条" if rows else "等待资料汇总")) + + read = limited = 0 + models = _mapping(data.get("models")) + per_model: list[tuple[str, int, int]] = [] + for key in ("qwen", "openai"): + files = _mapping(_mapping(models.get(key)).get("coverage")).get("files") + if not isinstance(files, list): + continue + processed = sum(1 for item in files if _mapping(item).get("status") == "processed") + blocked = sum(1 for item in files if _mapping(item).get("status") != "processed") + per_model.append((MODEL_NAMES[key], processed, blocked)) + read, limited = max(read, processed), max(limited, blocked) + attachments = int(_number(summary.get("attachment_count")) or (read + limited)) + self.waffle.set_counts(read, limited, attachments) + self._render_tiles(models) + # The tiles say more than the waffle whenever the files themselves were saved. + self.waffle.setVisible(attachments > 0 and self.tile_grid.count() == 0) + untouched = max(0, attachments - read - limited) + self.attachment_note.setText( + f"附件 {attachments} 个:模型实际读取 {read} 个,受限或不支持 {limited} 个" + + (f",未送达模型 {untouched} 个" if untouched else "") if attachments else "本次没有附件") + self.attachment_legend.setText( + f" 已读取 {read}" + f"   受限或不支持 {limited}" + + (f"   未送达 {untouched}" if untouched else "")) + self.per_model.setRowCount(len(per_model)) + for index, (name, processed, blocked) in enumerate(per_model): + self.per_model.setItem(index, 0, _item(name)) + self.per_model.setItem(index, 1, _item(str(processed), align_right=True)) + self.per_model.setItem(index, 2, _item(str(blocked), align_right=True)) + share = f"{processed / attachments * 100:.0f}%" if attachments else "—" + self.per_model.setItem(index, 3, _item(share, align_right=True)) + _fit_rows(self.per_model) + + missing = data.get("missing") + grouped: dict[str, dict[str, Any]] = {} + if isinstance(missing, list): + for item in missing: + value = _mapping(item) + code = _text(value.get("code")) or "UNKNOWN" + entry = grouped.setdefault(code, {"count": 0, "critical": False}) + entry["count"] += 1 + entry["critical"] = entry["critical"] or bool(value.get("critical")) + ordered = sorted(grouped.items(), key=lambda pair: (not pair[1]["critical"], -pair[1]["count"], pair[0])) + self.gaps.setRowCount(len(ordered)) + for index, (code, entry) in enumerate(ordered): + self.gaps.setItem(index, 0, _item(_label(code) or code, tooltip=code)) + self.gaps.setItem(index, 1, _item("关键" if entry["critical"] else "一般")) + self.gaps.setItem(index, 2, _item(str(entry["count"]), align_right=True)) + _fit_rows(self.gaps) + critical = sum(entry["count"] for _code, entry in ordered if entry["critical"]) + total_gaps = sum(entry["count"] for _code, entry in ordered) + self.gap_title.findChildren(QLabel)[-1].setText( + f"{total_gaps} 项 · 关键 {critical}" if total_gaps else "本批次没有记录缺口") + self.gap_note.setVisible(bool(total_gaps)) + + versions = _mapping(_mapping(data.get("models")).get("qwen")) + self.meta.setText("\n".join(part for part in ( + f"资料截止:{_text(data.get('cutoff_at')) or '—'}", + f"比较算法:{_text(versions.get('algorithm_version')) or '—'}", + f"提示词版本:{_text(versions.get('prompt_version')) or '—'}", + f"药材字典版本:{_text(versions.get('dictionary_version')) or '—'}", + f"覆盖状态:{_coverage_text(data.get('coverage_status'))}", + f"对照类型:{_label(data.get('comparison_type')) or '—'}", + ) if part)) + + TILE_COLUMNS = 4 + + def _render_tiles(self, models: Mapping[str, Any]) -> None: + """One tile per saved attachment, merged across models by file id.""" + + while self.tile_grid.count(): + item = self.tile_grid.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + merged: dict[str, dict[str, Any]] = {} + for key in ("qwen", "openai"): + files = _mapping(_mapping(_mapping(models).get(key)).get("coverage")).get("files") + if not isinstance(files, list): + continue + for entry in files: + value = _mapping(entry) + identifier = _text(value.get("file_id")) + if not identifier: + continue + kept = merged.get(identifier) + # A file counts as read when any model managed to read it; otherwise keep the + # first refusal so the tile can say why. + if kept is None or (value.get("status") == "processed" and kept.get("status") != "processed"): + merged[identifier] = value + for index, identifier in enumerate(sorted(merged)): + tile = AttachmentTile(merged[identifier], index + 1, self.tiles) + self.tile_grid.addWidget(tile, index // self.TILE_COLUMNS, index % self.TILE_COLUMNS) + self.tiles.setVisible(bool(merged)) + + def _source_row(self, name: str, value: int, colour: str, largest: int) -> QWidget: + row = QWidget(self.composition_rows) + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + label = QLabel(name, row) + label.setFixedWidth(70) + label.setStyleSheet(f"color: {TECH_BLUE['text']}; font-size: 12.5px;") + layout.addWidget(label) + bar = SourceBar(row) + bar.set_fraction(value / largest if largest else 0.0, colour) + bar.setAccessibleDescription(f"{name} {value}") + bar.setToolTip(f"{name} {value} 条;条形按本页最大项({largest} 条)归一。") + bar.setToolTip(f"{name} {value} 条;条形按本页最大项({largest} 条)归一。") + layout.addWidget(bar, 1) + count = QLabel(str(value), row) + count.setFixedWidth(34) + count.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + count.setStyleSheet( + f"color: {TECH_BLUE['heading'] if value else TECH_BLUE['muted']}; font-size: 12.5px; font-weight: 600;") + layout.addWidget(count) + return row + + +class DurationBar(QWidget): + """One call's latency against the slowest call in the batch.""" + + def __init__(self, fraction: float, colour: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._fraction = max(0.0, min(1.0, fraction)) + self._colour = colour + self.setFixedHeight(18) + self.setToolTip(f"占本批次最慢一次调用的 {self._fraction * 100:.0f}%") + self.setToolTip(f"占本批次最慢一次调用的 {self._fraction * 100:.0f}%") + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(f"{TECH_BLUE['raised']}")) + painter.drawRoundedRect(QRectF(0, 6, self.width(), 6), 3, 3) + if self._fraction > 0: + painter.setBrush(QColor(self._colour)) + painter.drawRoundedRect(QRectF(0, 6, max(3.0, self.width() * self._fraction), 6), 3, 3) + painter.end() + + +STAGE_GROUPS = (("text", "文字资料分析"), ("files", "附件读取"), ("final", "生成候选与报告"), + ("repair", "格式修复"), ("names", "药名回问"), ("insist", "补充追问"), ("reduce", "归并")) + + +class ProgressPage(QWidget): + """Per-model stages and the individual upstream calls behind them.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 12, 14, 10) + layout.setSpacing(10) + + stats = QHBoxLayout() + stats.setSpacing(12) + self.stat_values: dict[str, QLabel] = {} + for key, caption in (("elapsed", "批次总耗时"), ("calls", "模型调用"), + ("repairs", "格式修复 / 追问"), ("failures", "失败调用")): + card, card_layout = _card(self) + card_layout.setContentsMargins(18, 15, 18, 15) + card_layout.setSpacing(7) + head = QLabel(caption, card) + head.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 10.65px; letter-spacing: 0.85px;") + card_layout.addWidget(head) + value = QLabel("—", card) + value.setTextFormat(Qt.TextFormat.PlainText) + value.setFont(num_font(26, weight=QFont.Weight.DemiBold)) + value.setStyleSheet(f"color: {TECH_BLUE['heading']};") + card_layout.addWidget(value) + self.stat_values[key] = value + stats.addWidget(card, 1) + layout.addLayout(stats) + + self.model_row = QHBoxLayout() + self.model_row.setSpacing(12) + self.stage_labels: dict[str, QLabel] = {} + self.model_cards: dict[str, QVBoxLayout] = {} + self.stage_lists: dict[str, QVBoxLayout] = {} + for key, name in MODEL_NAMES.items(): + card, card_layout = _card(self) + head = QHBoxLayout() + head.setSpacing(8) + title = QLabel(name, card) + title.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 13.8px; font-weight: 600;") + head.addWidget(title) + stage = QLabel("等待处理进度", card) + stage.setWordWrap(True) + stage.setTextFormat(Qt.TextFormat.PlainText) + stage.setStyleSheet(f"color: {ACCENTS[key]}; font-size: 12.5px;") + head.addWidget(stage, 1) + card_layout.addLayout(head) + stages = QWidget(card) + stage_layout = QVBoxLayout(stages) + stage_layout.setContentsMargins(0, 4, 0, 0) + stage_layout.setSpacing(0) + card_layout.addWidget(stages) + card_layout.addStretch(1) + self.stage_labels[key] = stage + self.stage_lists[key] = stage_layout + self.model_cards[key] = card_layout + self.model_row.addWidget(card, 1) + layout.addLayout(self.model_row) + layout.addWidget(_title("调用记录", "耗时与用量来自上游返回;任务数不等于费用", self)) + self.calls = _table(["模型", "阶段", "耗时分布", "耗时", "附件", "输出 tokens", "结果"]) + layout.addWidget(self.calls) + + def attach_live(self, key: str, widgets: Sequence[QWidget]) -> None: + """Host the dialog's live progress widgets, which say more than the saved stage label.""" + + layout = self.model_cards[key] + self.stage_labels[key].setVisible(False) + for index, widget in enumerate(widgets): + layout.insertWidget(1 + index, widget) + + def set_batch(self, batch: Mapping[str, Any] | None) -> None: + data = _mapping(batch) + models = _mapping(data.get("models")) + rows: list[tuple[str, dict[str, Any]]] = [] + total_calls = repairs = failures = 0 + longest = 0.0 + elapsed_total = 0.0 + for key in ("qwen", "openai"): + model = _mapping(models.get(key)) + progress = _mapping(model.get("progress")) + attempt = progress.get("attempt") + elapsed = _number(progress.get("elapsed_seconds")) + parts = [_text(progress.get("stage_label")) or "等待处理进度"] + if attempt: + parts.append(f"第 {int(attempt)} 次尝试") + if elapsed is not None: + elapsed_total = max(elapsed_total, elapsed) + parts.append(f"已用时 {int(elapsed // 60)} 分 {int(elapsed % 60):02d} 秒") + notice = _text(progress.get("notice")) + self.stage_labels[key].setText(" · ".join(parts) + (f"\n{notice}" if notice else "")) + calls = _mapping(model.get("usage")).get("calls") + calls = [_mapping(call) for call in calls] if isinstance(calls, list) else [] + total_calls += len(calls) + repairs += sum(1 for call in calls if _text(call.get("stage")).partition(":")[0] + in {"repair", "names", "insist"}) + failures += sum(1 for call in calls if not call.get("ok")) + longest = max([longest] + [_number(call.get("latency_ms")) or 0 for call in calls]) + rows.extend((key, call) for call in calls) + self._render_stages(key, calls) + self.stat_values["elapsed"].setText( + f"{int(elapsed_total // 60)} 分 {int(elapsed_total % 60):02d} 秒" if elapsed_total else "—") + self.stat_values["calls"].setText(f"{total_calls} 次" if total_calls else "—") + self.stat_values["repairs"].setText(f"{repairs} 次" if total_calls else "—") + self.stat_values["failures"].setText(f"{failures} 次" if total_calls else "—") + + self.calls.setRowCount(len(rows)) + for index, (key, call) in enumerate(rows): + latency = _number(call.get("latency_ms")) + tokens = _number(_mapping(call.get("usage")).get("completion_tokens")) + files = _number(call.get("file_count")) + error = _text(call.get("error_code")) + self.calls.setItem(index, 0, _item(MODEL_NAMES[key])) + self.calls.setItem(index, 1, _item(_stage_text(call.get("stage")))) + self.calls.setItem(index, 2, _item("")) + self.calls.setCellWidget(index, 2, DurationBar((latency or 0) / longest if longest else 0.0, + ACCENTS[key], self.calls)) + self.calls.setItem(index, 3, _item("—" if latency is None else f"{latency / 1000:.1f} s", align_right=True)) + self.calls.setItem(index, 4, _item("—" if files is None else f"{int(files)}", align_right=True)) + self.calls.setItem(index, 5, _item("—" if tokens is None else f"{int(tokens)}", align_right=True)) + self.calls.setItem(index, 6, _item("通过" if call.get("ok") else (_label(error) or error or "失败"))) + self.calls.setColumnWidth(2, 120) + _fit_rows(self.calls) + + def _render_stages(self, key: str, calls: list[dict[str, Any]]) -> None: + """Stages are grouped from the saved calls, never invented when nothing was recorded.""" + + layout = self.stage_lists[key] + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + grouped: dict[str, tuple[int, float, int]] = {} + for call in calls: + head = _text(call.get("stage")).partition(":")[0] + count, seconds, failed = grouped.get(head, (0, 0.0, 0)) + grouped[head] = (count + 1, seconds + (_number(call.get("latency_ms")) or 0) / 1000, + failed + (0 if call.get("ok") else 1)) + for head, name in STAGE_GROUPS: + if head not in grouped: + continue + count, seconds, failed = grouped[head] + layout.addWidget(self._stage_row(name, count, seconds, failed)) + + def _stage_row(self, name: str, count: int, seconds: float, failed: int) -> QWidget: + row = QWidget(self) + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 5, 0, 5) + layout.setSpacing(10) + dot = QLabel("●", row) + tone = TECH_BLUE["rose_text"] if failed else TECH_BLUE["qwen_text"] + dot.setStyleSheet(f"color: {tone}; font-size: 9px;") + layout.addWidget(dot) + block = QVBoxLayout() + block.setSpacing(0) + title = QLabel(name, row) + title.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 12.5px; font-weight: 600;") + block.addWidget(title) + detail = QLabel(f"{count} 组" + (f" · {failed} 次未通过" if failed else ""), row) + detail.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11px;") + block.addWidget(detail) + layout.addLayout(block, 1) + time = QLabel(f"{seconds:.0f} 秒", row) + time.setStyleSheet(f"color: {TECH_BLUE['text']}; font-size: 12px;") + layout.addWidget(time) + return row + + +class HistoryPage(QWidget): + """Every saved batch of this prescription: the trend on the left, the reasons on the right.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 12, 14, 10) + layout.setSpacing(12) + + left, left_layout = _card(self) + head = QHBoxLayout() + head.setSpacing(10) + self.chart_title = _title("一致度趋势", "按时间从左到右 · 不可比的批次留空", left) + head.addWidget(self.chart_title, 1) + legend = QLabel( + f" {MODEL_NAMES['qwen']}" + f"   {MODEL_NAMES['openai']}", left) + legend.setStyleSheet("font-size: 11.5px;") + head.addWidget(legend) + left_layout.addLayout(head) + self.chart = TrendBars(left) + self.chart.setMaximumHeight(320) + left_layout.addWidget(self.chart, 1) + left_layout.addStretch(0) + self.strata = QLabel("", left) + self.strata.setWordWrap(True) + self.strata.setStyleSheet( + f"background: {TECH_BLUE['surface_2']}; border: 1px solid {TECH_BLUE['line']}; border-radius: 12px; padding: 10px 12px;" + f" color: {TECH_BLUE['muted']}; font-size: 11.5px;") + left_layout.addWidget(self.strata) + layout.addWidget(left, 5) + + right, right_layout = _card(self) + right.setMinimumWidth(420) + right_layout.addWidget(_title("批次列表", "保留全部历史", right)) + self.table = _table(["批次", "时间", "状态 / 原因", MODEL_NAMES["qwen"], MODEL_NAMES["openai"], "算法"]) + right_layout.addWidget(self.table, 1) + self.note = QLabel( + "进入医生维度统计的只有首次独立提交的批次;重试、重新分析与算法升级都不会重复增加样本。" + "跨算法或提示词版本的分数分层展示,不能直接相减。", right) + self.note.setWordWrap(True) + self.note.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;") + right_layout.addWidget(self.note) + layout.addWidget(right, 4) + + @staticmethod + def _score(model: Mapping[str, Any]) -> float | None: + comparison = _mapping(model.get("comparison")) + status = comparison.get("status") or model.get("comparison_status") + if status != "comparable": + return None + return _number(comparison.get("score", model.get("score"))) + + @staticmethod + def _version(entry: Mapping[str, Any]) -> str: + models = _mapping(entry.get("models")) + for key in ("qwen", "openai"): + version = _text(_mapping(models.get(key)).get("algorithm_version")) + if version: + return version.replace("prescription-soft-dice-", "") + return "—" + + @staticmethod + def _prompt(entry: Mapping[str, Any]) -> str: + models = _mapping(entry.get("models")) + for key in ("qwen", "openai"): + version = _text(_mapping(models.get(key)).get("prompt_version")) + if version: + return version + return "" + + def set_history(self, rows: list[Any]) -> None: + entries = [entry for entry in (_mapping(row) for row in rows) if entry] + entries.sort(key=lambda entry: (_text(entry.get("created_at")), _number(entry.get("id")) or 0)) + points = [] + for entry in entries: + models = _mapping(entry.get("models")) + point = {"label": "#" + _text(entry.get("id") or entry.get("batch_id"))} + for key in ("qwen", "openai"): + point[key] = self._score(_mapping(models.get(key))) + points.append(point) + self.chart.set_points(points) + self.chart_title.findChildren(QLabel)[-1].setText( + f"{len(entries)} 个批次 · 不可比的批次留空" if entries else "尚无历史批次") + + self.table.setRowCount(len(entries)) + for index, entry in enumerate(reversed(entries)): + models = _mapping(entry.get("models")) + self.table.setItem(index, 0, _item("#" + _text(entry.get("id") or entry.get("batch_id")))) + self.table.setItem(index, 1, _item(_text(entry.get("created_at"))[-8:] or "—")) + reason = _label(entry.get("validity")) or _label(entry.get("status")) or "—" + detail = _reason_text(entry) + state = _item(reason if not detail else f"{reason}\n{detail}", tooltip=detail) + self.table.setItem(index, 2, state) + for column, key in ((3, "qwen"), (4, "openai")): + score = self._score(_mapping(models.get(key))) + self.table.setItem(index, column, _item("—" if score is None else f"{score:.1f}%", align_right=True)) + self.table.setItem(index, 5, _item(self._version(entry))) + + strata: list[str] = [] + for previous, current in zip(entries, entries[1:], strict=False): + changes = [] + if self._version(previous) != self._version(current): + changes.append(f"比较算法 {self._version(previous)} → {self._version(current)}") + if self._prompt(previous) and self._prompt(previous) != self._prompt(current): + changes.append(f"提示词 {self._prompt(previous)} → {self._prompt(current)}") + if changes: + strata.append( + f"#{_text(previous.get('id'))} 与 #{_text(current.get('id'))} 之间:" + ";".join(changes) + + "。两侧分数属于不同分层,不能直接相减。") + self.strata.setText("\n".join(strata) if strata + else "全部批次使用同一套算法与提示词版本,分数可以直接比较。" if entries + else "尚无历史批次可供比较。") + + +def _reason_text(entry: Mapping[str, Any]) -> str: + """Why this batch exists or why it has no score — only what the record actually says.""" + + for field in ("invalid_reason", "regenerate_reason", "error_message", "error_code"): + value = _text(entry.get(field)) + if value: + return _label(value) or value + models = _mapping(entry.get("models")) + for key in ("qwen", "openai"): + model = _mapping(models.get(key)) + reason = _text(model.get("error_message") or model.get("error_code")) + if reason: + return f"{MODEL_NAMES[key]}:{_label(reason) or reason}" + return "" + + +class DistributionBars(QWidget): + """Agreement histogram: one pair of bars per 20% bin, counts printed on the axis.""" + + BINS = ("[0,20)", "[20,40)", "[40,60)", "[60,80)", "[80,100]") + LABELS = ("0–20%", "20–40%", "40–60%", "60–80%", "80–100%") + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._series: dict[str, list[int]] = {} + self.setMinimumHeight(190) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + def set_series(self, series: Mapping[str, Mapping[str, Any]]) -> None: + self._series = {} + for key in ("qwen", "openai"): + bins = _mapping(_mapping(series).get(key)) + if not bins: + continue + self._series[key] = [int(_number(bins.get(name)) or 0) for name in self.BINS] + described = " · ".join( + f"{MODEL_NAMES[key]} " + "/".join(str(value) for value in values) + for key, values in self._series.items()) + self.setAccessibleDescription("一致度分布(按 20% 分箱):" + (described or "暂无样本")) + self.setToolTip("\n".join( + f"{MODEL_NAMES[key]}:" + ",".join(f"{label} {count} 例" + for label, count in zip(self.LABELS, values, strict=True)) + for key, values in self._series.items()) or "暂无样本") + self.setToolTip("\n".join( + f"{MODEL_NAMES[key]}:" + ",".join(f"{label} {count} 例" + for label, count in zip(self.LABELS, values, strict=True)) + for key, values in self._series.items()) or "暂无样本") + self.update() + + def has_data(self) -> bool: + return any(any(values) for values in self._series.values()) + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + if not self.has_data(): + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + font = QFont(self.font()) + font.setPixelSize(10) + painter.setFont(font) + left, right, top, bottom = 44.0, self.width() - 8.0, 8.0, self.height() - 22.0 + ceiling = max(1, max(value for values in self._series.values() for value in values)) + for fraction in (0.0, 0.5, 1.0): + y = bottom - (bottom - top) * fraction + painter.setPen(QColor(f"{TECH_BLUE['raised']}")) + painter.drawLine(QPointF(left, y), QPointF(right, y)) + painter.setPen(QColor(TECH_BLUE["muted"])) + painter.drawText(QRectF(0, y - 8, left - 6, 16), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + f"{ceiling * fraction:.0f}" + (" 例" if fraction == 1.0 else "")) + slot = (right - left) / len(self.BINS) + width = min(16.0, slot / 3) + for index, label in enumerate(self.LABELS): + centre = left + slot * (index + 0.5) + for offset, key in ((-width * 0.6, "qwen"), (width * 0.6, "openai")): + values = self._series.get(key) + if not values: + continue + height = (bottom - top) * values[index] / ceiling + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(ACCENTS[key])) + painter.drawRoundedRect(QRectF(centre + offset - width / 2, bottom - height, width, height), 3, 3) + painter.setPen(QColor(TECH_BLUE["muted"])) + painter.drawText(QRectF(centre - slot / 2, bottom + 3, slot, 16), + Qt.AlignmentFlag.AlignCenter, label) + painter.end() + + +class FunnelBar(QWidget): + """A stage of the sample funnel: the label sits inside the bar it belongs to.""" + + def __init__(self, text: str, fraction: float, colour: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._text, self._fraction, self._colour = text, max(0.0, min(1.0, fraction)), colour + self.setFixedHeight(22) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setAccessibleDescription(text) + + def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(f"{TECH_BLUE['surface_2']}")) + painter.drawRoundedRect(QRectF(0, 0, self.width(), self.height()), 8, 8) + filled = max(2.0, self.width() * self._fraction) + painter.setBrush(QColor(self._colour)) + painter.drawRoundedRect(QRectF(0, 0, filled, self.height()), 8, 8) + light = self._colour not in {f"{TECH_BLUE['raised']}"} + painter.setPen(QColor(f"{TECH_BLUE['surface']}" if light else f"{TECH_BLUE['text']}")) + painter.drawText(QRectF(10, 0, self.width() - 20, self.height()), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._text) + painter.end() + + +class StatisticsPanel(QWidget): + """Doctor-level agreement: headline counts, distribution, sample funnel and per-doctor rows.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + self.kpis = QHBoxLayout() + self.kpis.setSpacing(12) + self.kpi_values: dict[str, QLabel] = {} + self.kpi_notes: dict[str, QLabel] = {} + for key, caption in (("events", "合格开方事件"), ("qwen", f"有效比较 · {MODEL_NAMES['qwen']}"), + ("openai", f"有效比较 · {MODEL_NAMES['openai']}"), ("review", "专家复核合格率")): + card, card_layout = _card(self) + title = QLabel(caption, card) + title.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11px;") + value = QLabel("—", card) + value.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 26px; font-weight: 700;") + note = QLabel("", card) + note.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;") + note.setWordWrap(True) + card_layout.addWidget(title) + card_layout.addWidget(value) + card_layout.addWidget(note) + card_layout.addStretch(1) + self.kpi_values[key] = value + self.kpi_notes[key] = note + self.kpis.addWidget(card, 1) + layout.addLayout(self.kpis) + + middle = QHBoxLayout() + middle.setSpacing(12) + chart_card, chart_layout = _card(self) + chart_card.setMaximumHeight(360) + head = QHBoxLayout() + head.setSpacing(10) + self.chart_title = _title("一致度分布", "按 20% 分箱", chart_card) + head.addWidget(self.chart_title, 1) + legend = QLabel( + f" {MODEL_NAMES['qwen']}" + f"   {MODEL_NAMES['openai']}", chart_card) + legend.setStyleSheet("font-size: 11.5px;") + head.addWidget(legend) + chart_layout.addLayout(head) + self.distribution = DistributionBars(chart_card) + chart_layout.addWidget(self.distribution, 1) + self.chart_empty = QLabel("尚无可比样本,暂不绘制分布。", chart_card) + self.chart_empty.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px;") + chart_layout.addWidget(self.chart_empty) + summary = QHBoxLayout() + summary.setSpacing(14) + self.summary_values: dict[str, QLabel] = {} + for key, caption in (("qwen", f"{MODEL_NAMES['qwen']} 均值 / 中位"), + ("openai", f"{MODEL_NAMES['openai']} 均值 / 中位"), ("paired", "配对共同样本")): + cell = QVBoxLayout() + cell.setSpacing(0) + head_label = QLabel(caption, chart_card) + head_label.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 10.5px;") + cell.addWidget(head_label) + value = QLabel("—", chart_card) + value.setTextFormat(Qt.TextFormat.PlainText) + value.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 17px; font-weight: 700;") + cell.addWidget(value) + self.summary_values[key] = value + summary.addLayout(cell, 1) + chart_layout.addLayout(summary) + middle.addWidget(chart_card, 5) + + funnel_card, funnel_layout = _card(self) + funnel_layout.setSpacing(6) + funnel_layout.addWidget(_title("样本口径", "排除项会影响代表性", parent=funnel_card)) + self.funnel_rows = QWidget(funnel_card) + self.funnel_layout = QVBoxLayout(self.funnel_rows) + self.funnel_layout.setContentsMargins(0, 2, 0, 0) + self.funnel_layout.setSpacing(6) + funnel_layout.addWidget(self.funnel_rows) + funnel_layout.addWidget(_title("排除原因", "合计全部医生", parent=funnel_card)) + self.exclusion_rows = QWidget(funnel_card) + self.exclusion_layout = QVBoxLayout(self.exclusion_rows) + self.exclusion_layout.setContentsMargins(0, 2, 0, 0) + self.exclusion_layout.setSpacing(6) + funnel_layout.addWidget(self.exclusion_rows) + self.warning = QLabel( + "一致度不是医生准确率:分数低可能只是治疗思路不同,分数高也不代表安全或有效。" + "要得到“准确率”,需要按机构规则做独立专家复核抽样;在建立复核样本前,本页不显示该数值。", funnel_card) + self.warning.setWordWrap(True) + self.warning.setStyleSheet( + f"background: {TECH_BLUE['amber_dim']}; border: 1px solid {TECH_BLUE['amber']}; border-radius: 12px; padding: 8px 10px;" + f" color: {TECH_BLUE['amber_text']}; font-size: 11.5px;") + funnel_layout.addWidget(self.warning) + middle.addWidget(funnel_card, 4) + layout.addLayout(middle, 1) + + table_card, table_layout = _card(self) + table_layout.addWidget(_title("按医生", "小样本不做排名", parent=table_card)) + self.doctors = _table(["医生", "开方", "患者", f"{MODEL_NAMES['qwen']} 有效/均值", + f"{MODEL_NAMES['openai']} 有效/均值", "配对样本", "复核合格率"]) + self.doctors.setMinimumHeight(140) + table_layout.addWidget(self.doctors, 1) + layout.addWidget(table_card, 1) + self.footnote = QLabel( + "一致度不是医生准确率:分数低可能只是治疗思路不同,分数高也不代表安全或有效;" + "专家复核合格率需要独立复核样本,未建立前显示“—”。", self) + self.footnote.setWordWrap(True) + self.footnote.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;") + layout.addWidget(self.footnote) + + def set_statistics(self, result: Mapping[str, Any] | None) -> None: + data = _mapping(result) + doctors = [_mapping(row) for row in (data.get("doctors") or [])] + events = int(_number(data.get("total_count")) or 0) + patients = int(_number(data.get("patient_count")) or 0) + self.kpi_values["events"].setText(str(events)) + self.kpi_notes["events"].setText(f"涉及患者 {patients} 人") + totals = {"qwen": 0, "openai": 0} + exclusions: dict[str, int] = {} + bins: dict[str, dict[str, int]] = {"qwen": {}, "openai": {}} + evaluated = qualified = paired = 0 + means: dict[str, list[float]] = {"qwen": [], "openai": []} + medians: dict[str, list[float]] = {"qwen": [], "openai": []} + for doctor in doctors: + models = _mapping(doctor.get("models")) + for key in ("qwen", "openai"): + model = _mapping(models.get(key)) + totals[key] += int(_number(model.get("eligible_count")) or 0) + mean = _number(model.get("mean")) + if mean is not None: + means[key].append(mean) + median = _number(model.get("median")) + if median is not None: + medians[key].append(median) + for code, count in _mapping(model.get("excluded_reasons")).items(): + exclusions[code] = exclusions.get(code, 0) + int(_number(count) or 0) + for name, count in _mapping(model.get("distribution")).items(): + bins[key][name] = bins[key].get(name, 0) + int(_number(count) or 0) + review = _mapping(doctor.get("review")) + evaluated += int(_number(review.get("evaluated_count")) or 0) + qualified += int(_number(review.get("qualified_count")) or 0) + paired += int(_number(doctor.get("paired_count")) or 0) + for key in ("qwen", "openai"): + self.kpi_values[key].setText(str(totals[key])) + coverage = (totals[key] / events * 100) if events else None + average = (sum(means[key]) / len(means[key])) if means[key] else None + self.kpi_notes[key].setText(" · ".join(part for part in ( + f"覆盖率 {coverage:.1f}%" if coverage is not None else "覆盖率 —", + f"均值 {average:.1f}%" if average is not None else "均值 —") if part)) + middle_value = (sum(medians[key]) / len(medians[key])) if medians[key] else None + self.summary_values[key].setText( + (f"{average:.1f}%" if average is not None else "—") + " / " + + (f"{middle_value:.1f}%" if middle_value is not None else "—")) + self.summary_values["paired"].setText(f"{paired} 例" if paired else "—") + self.kpi_values["review"].setText(f"{qualified / evaluated * 100:.1f}%" if evaluated else "—") + self.kpi_notes["review"].setText( + f"已复核 {evaluated} 例" if evaluated else "尚未建立复核样本,不用一致度代替") + + self.distribution.set_series(bins) + self.distribution.setVisible(self.distribution.has_data()) + self.chart_empty.setVisible(not self.distribution.has_data()) + self.chart_title.findChildren(QLabel)[-1].setText( + " / ".join(f"{MODEL_NAMES[key]} {totals[key]} 例" for key in ("qwen", "openai")) + " · 按 20% 分箱" + if events else "按 20% 分箱") + + stages = [("范围内开方事件", events)] + for key in ("qwen", "openai"): + stages.append((f"{MODEL_NAMES[key]} 有效基线比较", totals[key])) + stages.append(("两模型配对共同样本", paired)) + self._fill(self.funnel_rows, self.funnel_layout, [(name, value, TECH_BLUE["accent"]) for name, value in stages], events) + + ordered = sorted(exclusions.items(), key=lambda pair: (-pair[1], pair[0])) + largest = max([count for _code, count in ordered], default=0) + self._fill(self.exclusion_rows, self.exclusion_layout, + [(_label(code) or code, count, f"{TECH_BLUE['raised']}") for code, count in ordered], largest) + + self.doctors.setRowCount(len(doctors)) + for index, doctor in enumerate(doctors): + models = _mapping(doctor.get("models")) + review = _mapping(doctor.get("review")) + self.doctors.setItem(index, 0, _item(_text(doctor.get("doctor_name")) or _text(doctor.get("doctor_id")))) + self.doctors.setItem(index, 1, _item(str(int(_number(doctor.get("total_count")) or 0)), align_right=True)) + self.doctors.setItem(index, 2, _item(str(int(_number(doctor.get("patient_count")) or 0)), align_right=True)) + for column, key in ((3, "qwen"), (4, "openai")): + model = _mapping(models.get(key)) + eligible = int(_number(model.get("eligible_count")) or 0) + mean = _number(model.get("mean")) + self.doctors.setItem(index, column, _item( + f"{eligible} / {mean:.1f}%" if mean is not None else f"{eligible} / —", align_right=True)) + self.doctors.setItem(index, 5, _item(str(int(_number(doctor.get("paired_count")) or 0)), align_right=True)) + rate = _number(review.get("qualified_rate")) + self.doctors.setItem(index, 6, _item("—" if rate is None else f"{rate:.1f}%")) + + @staticmethod + def _fill(container: QWidget, layout: QVBoxLayout, rows: list[tuple[str, int, str]], largest: int) -> None: + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + for name, value, colour in rows: + layout.addWidget(FunnelBar(f"{name} {value}", value / largest if largest else 0.0, colour)) + # Bars have a fixed height; without a floor on the holder the card squeezes them together. + container.setMinimumHeight(len(rows) * 28 + 2 if rows else 0) diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_theme.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_theme.py new file mode 100644 index 000000000..f003977b1 --- /dev/null +++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_theme.py @@ -0,0 +1,210 @@ +"""The analysis console's palette, and the switch between its dark and light themes. + +The prescription analysis window has its own theme, separate from the workstation's light chrome: +tech blue for the interface itself, and two model hues (cyan for 千问, violet for OpenAI) that stay +apart from the interface colour and from each other. Severity keeps amber and rose, so a reading +never depends on hue alone. + +``CONSOLE`` is the active palette and is mutated in place by :func:`use_theme`, so every module +that imported it sees the new values. Anything derived from it — a stylesheet string, a colour +constant — must be rebuilt in a callback registered through :func:`on_theme_changed`. + +Key names mirror ``reception_style.TECH_BLUE`` so a widget can take either palette. +""" + +from __future__ import annotations + +import ctypes +import sys +from collections.abc import Callable +from typing import Any + +from PySide6.QtGui import QFont + +DARK: dict[str, str] = { + # ground and cards + "canvas": "#080D18", + "canvas_soft": "#0C1322", + "surface": "#0F1726", + "surface_2": "#131D2E", + "raised": "#1A2637", + "line": "#22304A", + "line_soft": "#18233A", + # type + "heading": "#E8EFFB", + "text": "#B6C5DA", + "muted": "#8195AF", + "faint": "#6A7F99", + "selected_text": "#E8EFFB", + # interface colour + "accent": "#2E7BF6", + "accent_text": "#6BA5FF", + "accent_pressed": "#1A5FD0", + "selection": "#142645", + "selection_line": "#1B3E77", + # model hues + "qwen": "#17BFDD", + "qwen_text": "#55D8EE", + "qwen_dim": "#10303C", + "openai": "#9B7BFF", + "openai_text": "#B69FFF", + "openai_dim": "#241F48", + # severity + "amber": "#F5B942", + "amber_text": "#F8C661", + "amber_dim": "#2C2415", + "rose": "#F4697A", + "rose_text": "#F88694", + "rose_dim": "#2C1620", + "ok": "#2E7BF6", + "zebra": "#0E1626", + "grid_line": "#191F2C", +} + +LIGHT: dict[str, str] = { + "canvas": "#F1F5FC", + "canvas_soft": "#E7EEFA", + "surface": "#FFFFFF", + "surface_2": "#F5F8FE", + "raised": "#E8EFFA", + "line": "#D5E1F2", + "line_soft": "#E3EBF8", + "heading": "#0A182E", + "text": "#2A3C56", + "muted": "#54697F", + "faint": "#7A8DA3", + "selected_text": "#0A182E", + "accent": "#1A5FD0", + "accent_text": "#124DAE", + "accent_pressed": "#0E3F90", + "selection": "#E8EFFA", + "selection_line": "#AEC7EE", + "qwen": "#0C89AC", + "qwen_text": "#086C88", + "qwen_dim": "#E2F5FA", + "openai": "#6B4AE0", + "openai_text": "#5537C6", + "openai_dim": "#EDE8FE", + "amber": "#B4791A", + "amber_text": "#8F5F0B", + "amber_dim": "#FCF2DF", + "rose": "#D0435A", + "rose_text": "#AE3149", + "rose_dim": "#FCE9EC", + "ok": "#1A5FD0", + "zebra": "#FAFBFE", + "grid_line": "#DDE4EE", +} + +CONSOLE: dict[str, str] = dict(DARK) +_THEME = "dark" +_LISTENERS: list[Callable[[], None]] = [] + + +def current_theme() -> str: + return _THEME + + +def on_theme_changed(callback: Callable[[], None]) -> Callable[[], None]: + """Register a rebuild for anything derived from the palette; returns the callback.""" + + _LISTENERS.append(callback) + return callback + + +def use_theme(name: str) -> str: + """Switch the active palette in place and let every derived value rebuild itself.""" + + global _THEME + palette = LIGHT if name == "light" else DARK + _THEME = "light" if name == "light" else "dark" + CONSOLE.clear() + CONSOLE.update(palette) + _rebuild() + return _THEME + + +def toggle_theme() -> str: + return use_theme("light" if _THEME == "dark" else "dark") + + +def _rebuild() -> None: + for callback in list(_LISTENERS): + callback() + + +MODEL_HUE = {"qwen": CONSOLE["qwen"], "openai": CONSOLE["openai"]} +MODEL_TEXT = {"qwen": CONSOLE["qwen_text"], "openai": CONSOLE["openai_text"]} +MODEL_DIM = {"qwen": CONSOLE["qwen_dim"], "openai": CONSOLE["openai_dim"]} + + +@on_theme_changed +def _rebuild_model_hues() -> None: + MODEL_HUE.update({"qwen": CONSOLE["qwen"], "openai": CONSOLE["openai"]}) + MODEL_TEXT.update({"qwen": CONSOLE["qwen_text"], "openai": CONSOLE["openai_text"]}) + MODEL_DIM.update({"qwen": CONSOLE["qwen_dim"], "openai": CONSOLE["openai_dim"]}) + + +RADIUS = 10 +RADIUS_SM = 7 + +# The design sets numbers in a condensed face so columns of figures line up; the fallbacks keep +# the same tabular behaviour when Bahnschrift is missing. +NUM_FAMILIES = ("Bahnschrift", "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei") + + +def num_font(size: int, *, weight: QFont.Weight | None = None) -> QFont: + """A tabular face for figures, at the pixel size the design states.""" + + font = QFont() + font.setFamilies(list(NUM_FAMILIES)) + font.setPixelSize(size) + if weight is not None: + font.setWeight(weight) + font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) + return font + + +# Windows draws the title bar itself, so the console's dark ground stops at the frame unless the +# window asks DWM for a matching caption. Windows 11 (build 22000+) honours these attributes; +# anywhere else the call fails and the native bar is left as it is. +_DWMWA_USE_IMMERSIVE_DARK_MODE = 20 +_DWMWA_BORDER_COLOR = 34 +_DWMWA_CAPTION_COLOR = 35 +_DWMWA_TEXT_COLOR = 36 + + +def _colorref(value: str) -> int: + """A ``#RRGGBB`` string as the ``0x00BBGGRR`` integer DWM expects.""" + + colour = value.lstrip("#") + red, green, blue = (int(colour[index:index + 2], 16) for index in (0, 2, 4)) + return (blue << 16) | (green << 8) | red + + +def apply_window_chrome(widget: Any) -> bool: + """Paint the native title bar in the palette that is active right now. + + Returns whether DWM accepted the change, so a caller can tell "not Windows 11" from "done". + """ + + if sys.platform != "win32": + return False + handle = int(widget.winId()) + if not handle: + return False + try: + dwm = ctypes.windll.dwmapi + except (AttributeError, OSError): # pragma: no cover - not Windows + return False + dark = ctypes.c_int(1 if _THEME == "dark" else 0) + caption = ctypes.c_uint(_colorref(CONSOLE["canvas"])) + text = ctypes.c_uint(_colorref(CONSOLE["heading"])) + border = ctypes.c_uint(_colorref(CONSOLE["line"])) + applied = False + for attribute, value in ((_DWMWA_USE_IMMERSIVE_DARK_MODE, dark), (_DWMWA_CAPTION_COLOR, caption), + (_DWMWA_TEXT_COLOR, text), (_DWMWA_BORDER_COLOR, border)): + result = dwm.DwmSetWindowAttribute(ctypes.c_void_p(handle), ctypes.c_int(attribute), + ctypes.byref(value), ctypes.sizeof(value)) + applied = applied or result == 0 + return applied diff --git a/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_workspace.py b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_workspace.py new file mode 100644 index 000000000..8797f9451 --- /dev/null +++ b/app/src/doctor_workstation/ui/dialogs/issued_prescription_ai_workspace.py @@ -0,0 +1,1307 @@ +"""Dose comparison with a persistent review rail and expandable supporting diagrams.""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass, field +from decimal import Decimal +from types import SimpleNamespace +from typing import Any + +from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal +from PySide6.QtGui import QColor, QFont, QPainter, QPaintEvent +from PySide6.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from ..reception_style import body_family +from .issued_prescription_ai_comparison import ( + MODEL_NAMES, + PrescriptionComparisonPanel, + _Dose, + _mapping, + _reason, + _Row, + _saved_rows, + _text, +) +from .issued_prescription_ai_pages import SourceBar, WrappedLabel, _label, _number +from .issued_prescription_ai_progress import ACTIVE_STATES, SUCCESS_STATES +from .issued_prescription_ai_theme import CONSOLE as TECH_BLUE +from .issued_prescription_ai_theme import num_font, on_theme_changed + +# The console gives each model its own hue, away from the interface blue and from each other. +COLORS = {"doctor": TECH_BLUE["muted"], "qwen": TECH_BLUE["qwen"], "openai": TECH_BLUE["openai"], + "accent": TECH_BLUE["accent"], "muted": TECH_BLUE["muted"]} +DANGER, WARN, MUTED_DOT = TECH_BLUE["rose_text"], TECH_BLUE["amber_text"], TECH_BLUE["muted"] + + +@on_theme_changed +def _rebuild_colours() -> None: + global DANGER, WARN, MUTED_DOT + COLORS.update({"doctor": TECH_BLUE["muted"], "qwen": TECH_BLUE["qwen"], + "openai": TECH_BLUE["openai"], "accent": TECH_BLUE["accent"], + "muted": TECH_BLUE["muted"]}) + DANGER, WARN, MUTED_DOT = TECH_BLUE["rose_text"], TECH_BLUE["amber_text"], TECH_BLUE["muted"] + + +@dataclass +class ReviewDoseRow: + """One saved correspondence; model rows only merge on a unique persisted key.""" + + token: str + entries: dict[str, _Row] = field(default_factory=dict) + enabled: dict[str, bool] = field(default_factory=dict) + + @property + def name(self) -> str: + return next(iter(self.entries.values())).name + + @property + def doctor(self) -> _Dose | None: + return next(iter(self.entries.values())).doctor + + @property + def doses(self) -> dict[str, _Dose | None]: + return {"doctor": self.doctor, **{key: row.candidate for key, row in self.entries.items()}} + + @property + def scale(self) -> tuple[str, str] | None: + if not all(self.enabled.values()): + return None + scales = {row.scale for row in self.entries.values()} + doses = [dose for dose in self.doses.values() if dose is not None] + if None in scales or len(scales) != 1 or not doses: + return None + if any(dose.number is None for dose in doses) or len({dose.identity for dose in doses}) != 1: + return None + return next(iter(scales)) + + @property + def changed(self) -> bool: + if self.scale is None: + return False + return any(row.doctor is not None and row.candidate is not None and row.doctor.number != row.candidate.number for row in self.entries.values()) + + @property + def description(self) -> str: + return "\n".join(row.description(MODEL_NAMES[key]) for key, row in self.entries.items()) + + +def review_rows(batch: dict[str, Any]) -> tuple[list[ReviewDoseRow], dict[str, str]]: + """Keep every candidate, including unnormalized originals and ambiguous duplicates.""" + result: list[ReviewDoseRow] = [] + keyed: dict[str, ReviewDoseRow] = {} + states: dict[str, str] = {} + for model_key in MODEL_NAMES: + model = _mapping(_mapping(batch.get("models")).get(model_key)) + candidate, comparison = _mapping(model.get("candidate")), _mapping(model.get("comparison")) + # The existing comparison state machine is deliberately shared; no UI is constructed. + enabled, message = PrescriptionComparisonPanel._state( + SimpleNamespace(_batch=batch, _model_key=model_key), model, candidate, comparison, + ) + # An unspecified validity is suitable for reading, never for the combined plot. + enabled = enabled and batch.get("validity") in {"current", "valid"} + if batch and batch.get("validity") not in {"current", "valid"} and "未注明" in message: + message = "报告有效性未确认;仅显示已保存剂量。" + states[model_key] = message.replace("条形", "图形") + saved = comparison.get("rows") + originals = [dict(row) for row in saved if isinstance(row, Mapping)] if isinstance(saved, list) else [] + counts = Counter(_text(row.get("key")) for row in originals) + for index, row in enumerate(_saved_rows(candidate, comparison)): + key = _text(originals[index].get("key")) if index < len(originals) else "" + reliable = bool(key) and counts[key] == 1 and row.origin == "comparison" + previous = keyed.get(key) if reliable else None + first = next(iter(previous.entries.values())) if previous else None + # A key alone cannot override contradictory names, baseline snapshots or units. + can_join = first is not None and first.name == row.name and first.doctor == row.doctor + if can_join: + first_doses = [dose for dose in (first.doctor, first.candidate, row.candidate) if dose is not None] + can_join = len({dose.identity for dose in first_doses}) == 1 and len({dose.scale for dose in first_doses}) == 1 + if can_join and previous is not None: + previous.entries[model_key] = row + previous.enabled[model_key] = enabled + else: + item = ReviewDoseRow(f"{model_key}:{index}:{key}", {model_key: row}, {model_key: enabled}) + result.append(item) + if reliable: + keyed[key] = item + return result, states + + +def diagnosis_text(model: dict[str, Any]) -> str: + """Only the diagnosis field is diagnostic evidence; a summary is never a diagnosis.""" + diagnosis = _mapping(model.get("report")).get("diagnosis") + if isinstance(diagnosis, Mapping): + labels = {"western_diagnosis": "西医诊断", "tcm_diagnosis": "中医诊断", "syndrome": "辨证"} + lines = [f"{label}:{_text(diagnosis.get(key))}" for key, label in labels.items() if _text(diagnosis.get(key))] + return "\n".join(lines) if lines else "诊断意见未保存" + prose = _text(diagnosis) + return prose or "诊断意见未保存" + + +def _model_status(model: dict[str, Any]) -> str: + status = _text(model.get("status")) + if status in ACTIVE_STATES: + return "生成中" + if status in SUCCESS_STATES: + return "已完成" + return {"partial": "部分完成", "failed": "分析失败", "cancelled": "已取消", "canceled": "已取消", "blocked": "已暂停"}.get(status, "尚无报告") + + +def _usage_text(prescription: dict[str, Any]) -> str: + parts = [_text(prescription.get("usage_instruction"))] + for key, pattern in (("times_per_day", "每日 {} 次"), ("usage_days", "共 {} 天"), + ("dosage_amount", "用量 {}"), ("dosage_bag_count", "每次 {} 包"), + ("bags_per_dose", "每剂 {} 包")): + if _text(prescription.get(key)): + parts.append(pattern.format(prescription[key])) + parts.extend(_text(prescription.get(key)) for key in ("usage_time", "usage_way", "usage_notes", "dietary_taboo")) + return " · ".join(filter(None, parts)) + + +def _display_dose(dose: _Dose | None, *, compact: bool) -> str: + if dose is None or not _text(dose.raw): + return "—" + return f"{_text(dose.raw)} {dose.unit}" if compact else dose.label + + +class DoseDelta: + """One herb's distance from the doctor's dose, per model, on a shared gram scale.""" + + def __init__(self, name: str, doctor: Decimal | None, unit: str) -> None: + self.name, self.doctor, self.unit = name, doctor, unit + self.doses: dict[str, Decimal | None] = {"qwen": None, "openai": None} + self.present: dict[str, bool] = {"qwen": False, "openai": False} + + @property + def deltas(self) -> dict[str, Decimal | None]: + result: dict[str, Decimal | None] = {} + for key, dose in self.doses.items(): + if dose is None: + result[key] = None + elif self.doctor is None: + result[key] = dose + else: + result[key] = dose - self.doctor + return result + + @property + def reach(self) -> Decimal: + return max((abs(value) for value in self.deltas.values() if value is not None), default=Decimal(0)) + + @property + def badge(self) -> str: + missing = [MODEL_NAMES[key] for key in ("qwen", "openai") if self.doses[key] is None] + if len(missing) == 2: + return "两模型均未收录" + if missing: + return f"{missing[0]} 未收录" + if all(value == 0 for value in self.deltas.values()) and self.doctor is not None: + return f"两模型一致 {_plain(self.doctor)} {self.unit}" + return "" + + @property + def description(self) -> str: + parts = [self.name] + for key in ("qwen", "openai"): + value = self.deltas[key] + parts.append(f"{MODEL_NAMES[key]} " + ("未收录" if value is None else f"{_signed(value)}{self.unit}")) + return " ".join(parts) + + +def _plain(value: Decimal) -> str: + text = format(value.normalize(), "f") + return text.rstrip(".") if "." in text else text + + +def _signed(value: Decimal) -> str: + if value == 0: + return "一致" + return ("+" if value > 0 else "−") + _plain(abs(value)) + + +def plot_scale(row: ReviewDoseRow) -> tuple[str, str] | None: + """Like ``ReviewDoseRow.scale``, except a herb a model simply never listed is allowed. + + An absent candidate is a fact worth drawing ("未收录"); a saved dose that cannot be read as a + number is not, so any unreadable value still takes the whole row off the chart. + """ + + if not row.enabled or not all(row.enabled.values()): + return None + scales = {entry.scale for entry in row.entries.values()} + if None in scales or len(scales) != 1: + return None + present = [dose for dose in row.doses.values() if dose is not None] + numbers = [dose for dose in present if dose.number is not None] + if not numbers or len(numbers) != len(present): + return None + if len({dose.identity for dose in numbers}) != 1: + return None + return next(iter(scales)) + + +def dose_deltas(rows: list[ReviewDoseRow]) -> list[DoseDelta]: + """Only comparable rows carry a delta; a row without a shared scale is left out.""" + + result: list[DoseDelta] = [] + for row in rows: + scale = plot_scale(row) + if scale is None: + continue + doctor = row.doctor.number if row.doctor is not None else None + delta = DoseDelta(row.name, doctor, scale[0]) + for key, entry in row.entries.items(): + delta.present[key] = True + delta.doses[key] = entry.candidate.number if entry.candidate is not None else None + if doctor is None and not any(delta.doses.values()): + continue + result.append(delta) + result.sort(key=lambda item: (-item.reach, item.name)) + return result + + +def _axis_extent(deltas: list[DoseDelta]) -> Decimal: + """A round, symmetric scale so the two sides of the centre line stay comparable.""" + + reach = max((item.reach for item in deltas), default=Decimal(0)) + if reach <= 0: + return Decimal(6) + step = Decimal(6) + while step < reach: + step += Decimal(6) + return step + + +class _DoseAxis(QWidget): + """The plot area of one row: centre line, two bars, and a badge when a dose is absent.""" + + def __init__(self, delta: DoseDelta, extent: Decimal, parent: QWidget | None = None, + *, show_badge: bool = False) -> None: + super().__init__(parent) + self.delta, self.extent = delta, extent + self.show_badge = show_badge + self.setFixedHeight(30) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setAccessibleName("剂量差异条") + self.setAccessibleDescription(delta.description) + self.setToolTip(delta.description) + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + middle = self.width() / 2 + painter.setPen(QColor(f"{TECH_BLUE['line_soft']}")) + for fraction in (0.25, 0.75): + painter.drawLine(QPointF(self.width() * fraction, 2), QPointF(self.width() * fraction, self.height() - 2)) + painter.setPen(QColor("#C7D3E4")) + painter.drawLine(QPointF(middle, 0), QPointF(middle, self.height())) + painter.setPen(Qt.PenStyle.NoPen) + for index, key in enumerate(("qwen", "openai")): + value = self.delta.deltas[key] + if value is None or value == 0: + continue + width = float(abs(value) / self.extent) * (self.width() / 2 - 4) + top = 4 + index * 12 + left = middle if value > 0 else middle - width + new_herb = self.delta.doctor is None + painter.setBrush(QColor("#A9CBF7" if new_herb and key == "qwen" else COLORS[key])) + painter.drawRoundedRect(QRectF(left, top, max(width, 3.0), 9), 4, 4) + badge = self.delta.badge if self.show_badge else "" + if badge: + painter.setFont(QFont(self.font().family(), 8)) + metrics = painter.fontMetrics() + width = metrics.horizontalAdvance(badge) + 16 + box = QRectF(middle + 6, (self.height() - 18) / 2, width, 18) + agreed = badge.startswith("两模型一致") + painter.setBrush(QColor("#E9F8EF" if agreed else f"{TECH_BLUE['surface_2']}")) + painter.setPen(QColor(f"{TECH_BLUE['qwen']}" if agreed else f"{TECH_BLUE['line']}")) + painter.drawRoundedRect(box, 7, 7) + painter.setPen(QColor(f"{TECH_BLUE['qwen_text']}" if agreed else MUTED_DOT)) + painter.drawText(box, Qt.AlignmentFlag.AlignCenter, badge) + painter.end() + + +class _AxisRuler(QWidget): + """The shared scale printed once, under the last row.""" + + def __init__(self, extent: Decimal, unit: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.extent, self.unit = extent, unit + self.setFixedHeight(16) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setAccessibleDescription(f"刻度 −{_plain(extent)} 到 +{_plain(extent)} {unit}") + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt virtual + painter = QPainter(self) + painter.setPen(QColor(TECH_BLUE["muted"])) + painter.setFont(QFont(self.font().family(), 8)) + half = self.extent / 2 + labels = ((0.0, f"−{_plain(self.extent)}"), (0.25, f"−{_plain(half)}"), (0.5, "0"), + (0.75, f"+{_plain(half)}"), (1.0, f"+{_plain(self.extent)}")) + for fraction, text in labels: + width = 90.0 + left = self.width() * fraction - width / 2 + align = Qt.AlignmentFlag.AlignCenter + if fraction == 0.0: + left, align = 0.0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + elif fraction == 1.0: + left, align = self.width() - width, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + painter.drawText(QRectF(left, 0, width, self.height()), align, text) + painter.end() + + +def card_qss() -> str: + """Panel styles for the palette that is active right now.""" + + return f""" +QFrame#AiPanel {{ background: {TECH_BLUE['surface']}; border: 1px solid {TECH_BLUE['line_soft']}; + border-radius: 10px; }} +QLabel#AiPanelTitle {{ color: {TECH_BLUE['heading']}; font-size: 13.5px; font-weight: 600; }} +QLabel#AiPanelHint {{ color: {TECH_BLUE['faint']}; font-size: 10.6px; }} +QLabel#AiPanelMore {{ color: {TECH_BLUE['muted']}; font-size: 10.6px; }} +""" + + +def _rule(parent: QWidget | None = None) -> QFrame: + """The hairline the design puts between list rows.""" + + line = QFrame(parent) + line.setFixedHeight(1) + line.setStyleSheet(f"background: {TECH_BLUE['line_soft']}; border: 0;") + return line + + +def _card(parent: QWidget | None = None) -> tuple[QFrame, QVBoxLayout]: + frame = QFrame(parent) + frame.setObjectName("AiPanel") + layout = QVBoxLayout(frame) + layout.setContentsMargins(18, 15, 18, 18) + layout.setSpacing(10) + return frame, layout + + +class HintLabel(QLabel): + """The quiet line beside a card title: it gives way before the card does, and elides.""" + + def __init__(self, text: str = "", parent: QWidget | None = None) -> None: + super().__init__(parent) + self._full = "" + self.setObjectName("AiPanelHint") + self.setTextFormat(Qt.TextFormat.PlainText) + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.setMinimumWidth(0) + self.setText(text) + + def setText(self, text: str) -> None: # noqa: N802 - Qt virtual + self._full = text + self.setToolTip(text) + self._elide() + + def text(self) -> str: + return self._full + + def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + super().resizeEvent(event) + self._elide() + + def _elide(self) -> None: + width = max(0, self.width()) + shown = self.fontMetrics().elidedText(self._full, Qt.TextElideMode.ElideRight, width) if width else "" + QLabel.setText(self, shown) + + +def _panel_head(title: str, hint: str = "", more: str = "", + *, parent: QWidget | None = None) -> tuple[QWidget, HintLabel, QPushButton | None]: + """Title, quiet hint and an optional right-hand link — the header every card uses.""" + + holder = QWidget(parent) + row = QHBoxLayout(holder) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(10) + label = QLabel(title, holder) + label.setObjectName("AiPanelTitle") + row.addWidget(label) + note = HintLabel(hint, holder) + row.addWidget(note, 1) + link: QPushButton | None = None + if more: + link = QPushButton(more, holder) + link.setObjectName("AiPanelMore") + link.setCursor(Qt.CursorShape.PointingHandCursor) + link.setFlat(True) + link.setStyleSheet( + f"QPushButton {{ border: 0; background: transparent; color: {TECH_BLUE['muted']}; font-size: 10.6px;" + " padding: 0; }" + f"QPushButton:hover {{ color: {TECH_BLUE['accent']}; }}") + row.addWidget(link) + return holder, note, link + + +class DoseDiffPanel(QFrame): + """Every comparable herb as a distance from the doctor's dose, both models on one scale.""" + + NAME_WIDTH = 92 + VALUE_WIDTH = 52 + PLOT_FLOOR = 430 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AiPanel") + self.rows: list[DoseDelta] = [] + self._cells: list[tuple[DoseDelta, QLabel, _DoseAxis, QLabel, QLabel]] = [] + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 14, 18, 12) + layout.setSpacing(8) + head, self.scale_note, _link = _panel_head( + "剂量差异分布", "相对原生处方的剂量差异(克 / 每剂)", parent=self) + legend = QLabel( + f" {MODEL_NAMES['qwen']}" + f"    {MODEL_NAMES['openai']}", self) + legend.setStyleSheet("font-size: 12px;") + head.layout().addWidget(legend) + layout.addWidget(head) + self.scroll = QScrollArea(self) + self.scroll.setWidgetResizable(True) + self.scroll.setFrameShape(QFrame.Shape.NoFrame) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.body = QWidget(self.scroll) + self.body_layout = QVBoxLayout(self.body) + self.body_layout.setContentsMargins(0, 2, 0, 2) + self.body_layout.setSpacing(0) + self.scroll.setWidget(self.body) + layout.addWidget(self.scroll, 1) + self.empty = QLabel("分析完成后,在此查看每一味药与原处方的剂量差。", self) + self.empty.setWordWrap(True) + self.empty.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px;") + layout.addWidget(self.empty) + self.ruler_holder = QWidget(self) + ruler_row = QHBoxLayout(self.ruler_holder) + ruler_row.setContentsMargins(0, 0, 0, 0) + ruler_row.setSpacing(10) + self.ruler_left = QWidget(self.ruler_holder) + ruler_row.addWidget(self.ruler_left) + self.ruler = _AxisRuler(Decimal(24), "克", self.ruler_holder) + ruler_row.addWidget(self.ruler, 1) + self.ruler_caption = QLabel("剂量差异(克)", self.ruler_holder) + self.ruler_caption.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 9.9px;") + self.ruler_caption.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + ruler_row.addWidget(self.ruler_caption) + layout.addWidget(self.ruler_holder) + self._apply_widths() + + def narrow(self) -> bool: + return self.width() < self.PLOT_FLOOR + + def _apply_widths(self) -> None: + narrow = self.narrow() + name_width = 92 if not narrow else 80 + for delta, name, axis, qwen, openai in self._cells: + name.setFixedWidth(name_width) + axis.setVisible(not narrow) + for cell, key in ((qwen, "qwen"), (openai, "openai")): + cell.setFixedWidth(self.VALUE_WIDTH) + cell.setText(self._value_text(delta, key, narrow)) + self.ruler_left.setFixedWidth(name_width) + self.ruler_caption.setFixedWidth(self.VALUE_WIDTH * 2 + 10) + self.ruler_holder.setVisible(bool(self.rows) and not narrow) + + @staticmethod + def _value_text(delta: DoseDelta, key: str, narrow: bool) -> str: + value = delta.deltas[key] + if value is None: + return f"" + if value == 0: + return f"0" + return f"{_signed(value)}" + + def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + super().resizeEvent(event) + self._apply_widths() + + def set_rows(self, deltas: list[DoseDelta]) -> None: + self.rows = deltas + self._cells = [] + while self.body_layout.count(): + item = self.body_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + extent = _axis_extent(deltas) + unit = deltas[0].unit if deltas else "克" + for delta in deltas: + self.body_layout.addWidget(self._row(delta, extent)) + self.body_layout.addStretch(1) + self.ruler.extent, self.ruler.unit = extent, unit + self.ruler.setAccessibleDescription(f"刻度 −{_plain(extent)} 到 +{_plain(extent)} {unit}") + self.ruler.update() + self.scroll.setVisible(bool(deltas)) + self.empty.setVisible(not deltas) + self._apply_widths() + + def _row(self, delta: DoseDelta, extent: Decimal) -> QWidget: + row = QWidget(self.body) + row.setMinimumHeight(38) + row.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 8, 0, 8) + layout.setSpacing(12) + name = QLabel(delta.name, row) + name.setTextFormat(Qt.TextFormat.PlainText) + name.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 12.3px;") + name.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + name.setToolTip(delta.description) + layout.addWidget(name) + axis = _DoseAxis(delta, extent, row) + layout.addWidget(axis, 1) + cells = [] + for _model in ("qwen", "openai"): + cell = QLabel(row) + cell.setTextFormat(Qt.TextFormat.RichText) + cell.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + cell.setFont(num_font(12)) + layout.addWidget(cell) + cells.append(cell) + self._cells.append((delta, name, axis, cells[0], cells[1])) + return row + + +class ReadingNote(WrappedLabel): + """The "how to read this" line every chart in the console carries.""" + + def __init__(self, text: str, parent: QWidget | None = None) -> None: + mark = f"?" + super().__init__(f"{mark}  {text}", parent) + self.setTextFormat(Qt.TextFormat.RichText) + self.setStyleSheet( + f"background: {TECH_BLUE['surface_2']}; color: {TECH_BLUE['muted']};" + f" border: 1px dashed {TECH_BLUE['line']}; border-radius: 7px;" + " padding: 9px 12px; font-size: 10.8px; line-height: 170%;") + + +class ConclusionsPanel(QFrame): + """What this batch says, in numbered sentences built from the saved numbers only.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AiPanel") + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 15, 18, 18) + layout.setSpacing(11) + head, _hint, _link = _panel_head("本批结论", "自动生成 · 供复核", parent=self) + layout.addWidget(head) + self.scroll = QScrollArea(self) + self.scroll.setWidgetResizable(True) + self.scroll.setFrameShape(QFrame.Shape.NoFrame) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.body = QWidget(self.scroll) + self.body_layout = QVBoxLayout(self.body) + self.body_layout.setContentsMargins(0, 0, 6, 0) + self.body_layout.setSpacing(0) + self.scroll.setWidget(self.body) + layout.addWidget(self.scroll, 1) + layout.addWidget(ReadingNote( + "结论由规则依据下方图表生成,不代表医生判断;每条都可在对应卡片中核对。", self)) + + def set_items(self, items: list[str]) -> None: + while self.body_layout.count(): + entry = self.body_layout.takeAt(0) + widget = entry.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + for index, text in enumerate(items, start=1): + if index > 1: + self.body_layout.addWidget(_rule(self.body)) + self.body_layout.addWidget(self._item(index, text)) + self.body_layout.addStretch(1) + + def _item(self, index: int, text: str) -> QWidget: + row = QWidget(self.body) + policy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + policy.setHeightForWidth(True) + row.setSizePolicy(policy) + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 10, 0, 10) + layout.setSpacing(10) + number = QLabel(str(index), row) + number.setFixedSize(20, 20) + number.setAlignment(Qt.AlignmentFlag.AlignCenter) + number.setFont(num_font(10, weight=QFont.Weight.DemiBold)) + number.setStyleSheet( + f"background: {TECH_BLUE['selection']}; color: {TECH_BLUE['accent_text']}; border-radius: 6px;") + layout.addWidget(number, 0, Qt.AlignmentFlag.AlignTop) + body = QLabel(text, row) + body.setWordWrap(True) + body.setTextFormat(Qt.TextFormat.RichText) + body.setStyleSheet(f"color: {TECH_BLUE['text']}; font-size: 12.3px; line-height: 160%;") + layout.addWidget(body, 1) + return row + + +def conclusions(batch: dict, deltas: list[DoseDelta]) -> list[str]: + """Four sentences a reader can check against the cards below; nothing is inferred clinically.""" + + data = _mapping(batch) + models = _mapping(data.get("models")) + lines: list[str] = [] + counts = {} + for key in ("qwen", "openai"): + herbs = _mapping(_mapping(models.get(key)).get("candidate")).get("herbs") + counts[key] = len(herbs) if isinstance(herbs, list) else None + if counts["qwen"] is not None and counts["openai"] is not None: + lines.append(f"两个模型都给出了候选方:{MODEL_NAMES['qwen']} {counts['qwen']} 味," + f"{MODEL_NAMES['openai']} {counts['openai']} 味。") + else: + given = [MODEL_NAMES[key] for key in ("qwen", "openai") if counts[key] is not None] + lines.append(";".join(f"{name} 已给出候选方" for name in given) + ";其余模型本批次没有候选方。" + if given else "本批次尚无候选方,以下图表只反映已保存的部分。") + + scores = {} + for key in ("qwen", "openai"): + comparison = _mapping(_mapping(models.get(key)).get("comparison")) + scores[key] = _number(comparison.get("score")) if comparison.get("status") == "comparable" else None + if scores["qwen"] is not None and scores["openai"] is not None: + gap = scores["qwen"] - scores["openai"] + leader = MODEL_NAMES["qwen"] if gap >= 0 else MODEL_NAMES["openai"] + overlaps = [] + for key in ("qwen", "openai"): + herb = _number(_mapping(_mapping(models.get(key)).get("comparison")).get("herb_score")) + overlaps.append("—" if herb is None else f"{herb:.1f}%") + lines.append(f"{MODEL_NAMES['qwen']}一致率 {scores['qwen']:.1f}%," + f"{MODEL_NAMES['openai']} {scores['openai']:.1f}%," + f"{leader}领先 {abs(gap):.1f}pt;药味重合 {overlaps[0]} / {overlaps[1]}。") + else: + lines.append("两个模型都可比之后,这里给出一致率差值;现在只有可比的一侧有分数。") + + changed = [delta for delta in deltas + if any(value not in (None, 0) for value in delta.deltas.values())] + if changed: + top = sorted(changed, key=lambda delta: -delta.reach)[:3] + named = "、".join(f"{delta.name} {_signed(delta.reach)}{delta.unit}" for delta in top) + lines.append(f"剂量偏差共 {len(changed)} 味,最大的是 {named}。") + else: + lines.append("本批次没有可比的剂量偏差:不是没有差别,而是缺少可比的共同标尺。") + + gaps = data.get("missing") + if isinstance(gaps, list) and gaps: + critical = sum(1 for item in gaps if _mapping(item).get("critical")) + buckets: dict[str, int] = {} + for item in gaps: + name = _label(_mapping(item).get("code")) or "其他缺口" + buckets[name] = buckets.get(name, 0) + 1 + largest = max(buckets.items(), key=lambda pair: pair[1]) + lines.append(f"资料缺口 {len(gaps)} 项(关键 {critical} 项),其中「{largest[0]}」{largest[1]} 项," + "建议补录后重新分析。") + else: + lines.append("本批次没有记录资料缺口。") + return lines + + +class AttributionPanel(QFrame): + """Who used which herb: four groups, each with its count, its bar and its names.""" + + GROUPS = (("all", "三方共识", "accent"), ("doctor_only", "医方独有", "muted"), + ("qwen_only", f"{MODEL_NAMES['qwen']}独有", "qwen"), + ("openai_only", f"{MODEL_NAMES['openai']} 独有", "openai")) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AiPanel") + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 15, 18, 18) + layout.setSpacing(11) + head, self.total_note, _link = _panel_head("药味归属分布", "等待候选方", parent=self) + legend = QLabel( + f" {MODEL_NAMES['qwen']}" + f"   {MODEL_NAMES['openai']}" + f"   医方独有", self) + legend.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 10.65px;") + head.layout().addWidget(legend) + layout.addWidget(head) + self.scroll = QScrollArea(self) + self.scroll.setWidgetResizable(True) + self.scroll.setFrameShape(QFrame.Shape.NoFrame) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + groups = QWidget(self.scroll) + group_layout = QVBoxLayout(groups) + group_layout.setContentsMargins(0, 0, 6, 0) + group_layout.setSpacing(13) + self.scroll.setWidget(groups) + layout.addWidget(self.scroll, 1) + self.rows: dict[str, dict[str, Any]] = {} + for key, caption, tone in self.GROUPS: + block = QVBoxLayout() + block.setSpacing(5) + line = QHBoxLayout() + line.setSpacing(8) + name = QLabel(caption, self) + name.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 11.9px;") + line.addWidget(name) + line.addStretch(1) + count = QLabel("—", self) + count.setTextFormat(Qt.TextFormat.PlainText) + count.setFont(num_font(13, weight=QFont.Weight.DemiBold)) + count.setStyleSheet(f"color: {TECH_BLUE['heading']};") + line.addWidget(count) + block.addLayout(line) + bar = SourceBar(self) + block.addWidget(bar) + names = QLabel("—", self) + names.setWordWrap(True) + names.setTextFormat(Qt.TextFormat.PlainText) + names.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 10.65px; line-height: 160%;") + block.addWidget(names) + group_layout.addLayout(block) + self.rows[key] = {"count": count, "bar": bar, "names": names, + "colour": COLORS.get(tone, TECH_BLUE["muted"])} + group_layout.addStretch(1) + layout.addWidget(ReadingNote( + "条形长度 = 该归属下的药味数量;比韦恩图更容易直接读出「哪些药该重点看」。", self)) + + def apply(self, batch: dict) -> None: + sets = herb_sets(batch) + groups = { + "all": sets["doctor"] & sets["qwen"] & sets["openai"], + "doctor_only": sets["doctor"] - sets["qwen"] - sets["openai"], + "qwen_only": sets["qwen"] - sets["doctor"] - sets["openai"], + "openai_only": sets["openai"] - sets["doctor"] - sets["qwen"], + } + largest = max((len(value) for value in groups.values()), default=0) + total = len(sets["doctor"] | sets["qwen"] | sets["openai"]) + # A herb used by the doctor and only one model belongs to none of the four groups; say so + # rather than letting the four counts silently fail to add up. + paired = total - sum(len(value) for value in groups.values()) + text = (f"合计 {total} 味" + (f" · 另 {paired} 味为医方与单一模型共用" if paired else "")) if total else "等待候选方" + self.total_note.setText(text) + self.total_note.setToolTip(text) + for key, names in groups.items(): + row = self.rows[key] + row["count"].setText(f"{len(names)} 味" if total else "—") + row["bar"].set_fraction(len(names) / largest if largest else 0.0, row["colour"]) + ordered = sorted(names) + shown = "、".join(ordered[:6]) + (f" 等 {len(ordered)} 味" if len(ordered) > 6 else "") + row["names"].setText(shown or "—") + row["bar"].setToolTip("、".join(ordered) or "本组没有药味") + row["names"].setToolTip("、".join(ordered) or "本组没有药味") + + +def herb_sets(batch: dict) -> dict[str, set[str]]: + """The three prescriptions as name sets, read from the saved candidates and comparison rows.""" + + data = _mapping(batch) + models = _mapping(data.get("models")) + sets: dict[str, set[str]] = {"doctor": set(), "qwen": set(), "openai": set()} + for key in ("qwen", "openai"): + herbs = _mapping(_mapping(models.get(key)).get("candidate")).get("herbs") + if isinstance(herbs, list): + sets[key] = {_text(_mapping(herb).get("name")) for herb in herbs + if _text(_mapping(herb).get("name"))} + rows = _mapping(_mapping(models.get(key)).get("comparison")).get("rows") + if isinstance(rows, list): + sets["doctor"] |= {_text(_mapping(row).get("name")) for row in rows + if _mapping(row).get("doctor") is not None and _text(_mapping(row).get("name"))} + return sets + + +class RiskPanel(QFrame): + """Dose changes large enough to be worth a second look, with the rule that flagged them.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AiPanel") + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 15, 18, 18) + layout.setSpacing(11) + head, self.hint, _link = _panel_head("高风险提示", "建议优先复核", parent=self) + layout.addWidget(head) + self.body = QWidget(self) + self.body_layout = QVBoxLayout(self.body) + self.body_layout.setContentsMargins(0, 0, 0, 0) + self.body_layout.setSpacing(0) + layout.addWidget(self.body, 1) + self.empty = QLabel("本批次没有达到提示阈值的剂量变化。", self) + self.empty.setWordWrap(True) + self.empty.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px;") + layout.addWidget(self.empty) + layout.addWidget(ReadingNote( + "规则:剂量偏差 ≥ 10 克或成倍数 ≥ 50%,自动进入本清单。阈值只做排序,不替代医生判断。", self)) + + def set_rows(self, deltas: list[DoseDelta]) -> None: + while self.body_layout.count(): + entry = self.body_layout.takeAt(0) + widget = entry.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + flagged = [] + for delta in deltas: + for key in ("qwen", "openai"): + value = delta.deltas[key] + if value is None or value == 0: + continue + ratio = abs(value) / delta.doctor if delta.doctor else None + if abs(value) < 10 and (ratio is None or ratio < Decimal("0.5")): + continue + flagged.append((delta, key, value, ratio)) + flagged.sort(key=lambda item: -abs(item[2])) + for delta, key, value, ratio in flagged[:5]: + self.body_layout.addWidget(self._row(delta, key, value, ratio)) + self.body_layout.addStretch(1) + self.body.setVisible(bool(flagged)) + self.empty.setVisible(not flagged) + self.hint.setText(f"建议优先复核 · {len(flagged)} 项" if flagged else "建议优先复核") + + def _row(self, delta: DoseDelta, key: str, value: Decimal, ratio: Decimal | None) -> QWidget: + row = QWidget(self.body) + row.setMinimumHeight(56) + policy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + policy.setHeightForWidth(True) + row.setSizePolicy(policy) + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 8, 0, 8) + layout.setSpacing(10) + block = QVBoxLayout() + block.setSpacing(2) + title = QLabel(f"{delta.name} {_signed(value)} {delta.unit}", row) + title.setTextFormat(Qt.TextFormat.PlainText) + title.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 13px; font-weight: 600;") + block.addWidget(title) + if delta.doctor is None: + reason = f"{MODEL_NAMES[key]}新增,医方原方未收录,需确认加入理由" + elif ratio is not None and ratio >= 1: + reason = f"{MODEL_NAMES[key]}剂量为医方 {1 + ratio:.1f} 倍,请评估安全性" + elif ratio is not None: + reason = f"{MODEL_NAMES[key]}相对医方变动 {ratio * 100:.0f}%,请确认是否有意调整" + else: + reason = f"{MODEL_NAMES[key]}与医方相差 {abs(value)}{delta.unit}" + note = QLabel(reason, row) + note.setWordWrap(True) + note.setTextFormat(Qt.TextFormat.PlainText) + note.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 11.5px;") + block.addWidget(note) + layout.addLayout(block, 1) + severe = abs(value) >= 15 or (ratio is not None and ratio >= 1) + pill = QLabel("高" if severe else "中", row) + pill.setFixedWidth(26) + pill.setAlignment(Qt.AlignmentFlag.AlignCenter) + tone = ("rose_dim", "rose_text") if severe else ("amber_dim", "amber_text") + pill.setStyleSheet( + f"background: {TECH_BLUE[tone[0]]}; color: {TECH_BLUE[tone[1]]}; border-radius: 7px;" + " padding: 2px 0; font-size: 11.5px;") + layout.addWidget(pill, 0, Qt.AlignmentFlag.AlignTop) + return row + + +def restricted_attachments(batch: dict) -> int: + """Attachments no model managed to read, merged by file id across the two models.""" + + models = _mapping(_mapping(batch).get("models")) + states: dict[str, bool] = {} + for key in ("qwen", "openai"): + files = _mapping(_mapping(models.get(key)).get("coverage")).get("files") + if not isinstance(files, list): + continue + for entry in files: + value = _mapping(entry) + identifier = _text(value.get("file_id")) or str(len(states)) + read = value.get("status") == "processed" + states[identifier] = states.get(identifier, False) or read + return sum(1 for read in states.values() if not read) + + +def gap_counts(batch: dict) -> dict[str, int]: + """Saved gaps split into the three buckets the rail shows; nothing is inferred.""" + + counts = {"critical": 0, "attachment": 0, "other": 0} + missing = _mapping(batch).get("missing") + if not isinstance(missing, list): + return counts + for item in missing: + value = _mapping(item) + code = _text(value.get("code")).upper() + if value.get("critical"): + counts["critical"] += 1 + elif "ATTACHMENT" in code or "FILE" in code or "STORAGE" in code: + counts["attachment"] += 1 + else: + counts["other"] += 1 + return counts + + +GROUP_RULES = (("血压", "数据缺失"), ("肝肾", "数据缺失"), ("体重", "数据缺失"), ("BMI", "数据缺失"), + ("血糖", "数据缺失"), ("用药记录", "关键临床资料缺失"), ("舌象", "关键临床资料缺失"), + ("脉", "关键临床资料缺失"), ("甲状腺", "关键临床资料缺失"), ("眼底", "关键临床资料缺失")) + + +def checklist_group(text: str) -> str: + """Fold near-identical gap wordings into the short label the list shows.""" + + for needle, name in GROUP_RULES: + if needle in text: + return name + return text + + +class ChecklistPanel(QFrame): + """Everything that still needs a human decision, by severity, with the review form below.""" + + open_details = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("AiPanel") + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 15, 18, 18) + layout.setSpacing(9) + head, self.count_note, link = _panel_head( + "复核清单", "等待报告", "查看详情 ›", parent=self) + if link is not None: + link.clicked.connect(self.open_details.emit) + layout.addWidget(head) + self.scroll = QScrollArea(self) + self.scroll.setWidgetResizable(True) + self.scroll.setFrameShape(QFrame.Shape.NoFrame) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll.setMinimumHeight(84) + self.body = QWidget(self.scroll) + self.body_layout = QVBoxLayout(self.body) + self.body_layout.setContentsMargins(0, 0, 0, 0) + self.body_layout.setSpacing(0) + self.scroll.setWidget(self.body) + layout.addWidget(self.scroll, 1) + self.empty = QLabel("报告未保存待核对项目;请结合诊断依据复核。", self) + self.empty.setWordWrap(True) + self.empty.setStyleSheet(f"color: {TECH_BLUE['muted']}; font-size: 12px;") + layout.addWidget(self.empty) + divider = QFrame(self) + divider.setFixedHeight(1) + divider.setStyleSheet(f"background: {TECH_BLUE['line_soft']};") + layout.addWidget(divider) + caption_row = QHBoxLayout() + caption_row.setSpacing(8) + caption = QLabel("复核意见", self) + caption.setObjectName("AiPanelHint") + caption_row.addWidget(caption) + caption_row.addStretch(1) + self.model_slot = QWidget(self) + model_layout = QHBoxLayout(self.model_slot) + model_layout.setContentsMargins(0, 0, 0, 0) + model_layout.setSpacing(0) + caption_row.addWidget(self.model_slot) + layout.addLayout(caption_row) + self.review_slot = QWidget(self) + self.review_layout = QVBoxLayout(self.review_slot) + self.review_layout.setContentsMargins(0, 0, 0, 0) + self.review_layout.setSpacing(0) + layout.addWidget(self.review_slot) + layout.addWidget(ReadingNote( + "仅保存当前所选模型的复核结果,另一模型的报告保持不变。", self)) + + def set_items(self, items: list[tuple[str, str, str]]) -> None: + while self.body_layout.count(): + entry = self.body_layout.takeAt(0) + widget = entry.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + grouped: dict[str, tuple[str, str, int, str]] = {} + for text, attribution, tone in items: + key = checklist_group(text) + name, kept_tone, count, note = grouped.get(key, (key, tone, 0, attribution)) + grouped[key] = (name, kept_tone if _rank(kept_tone) <= _rank(tone) else tone, + count + 1, note or attribution) + ordered = sorted(grouped.values(), key=lambda entry: (_rank(entry[1]), -entry[2], entry[0])) + for position, (name, tone, count, note) in enumerate(ordered): + if position: + self.body_layout.addWidget(_rule(self.body)) + self.body_layout.addWidget(self._note(name, note, tone, count)) + self.body_layout.addStretch(1) + total = sum(count for _name, _tone, count, _note in ordered) + self.count_note.setText(f"{total} 条 · 按严重度排序" if total else "暂无待确认项") + self.scroll.setVisible(bool(ordered)) + self.empty.setVisible(not ordered) + + def _note(self, text: str, attribution: str, tone: str, count: int) -> QWidget: + row = QWidget(self.body) + row.setMinimumHeight(46) + policy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + policy.setHeightForWidth(True) + row.setSizePolicy(policy) + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 11, 0, 11) + layout.setSpacing(11) + severe = tone == DANGER + pill = QLabel("关键" if severe else "一般", row) + pill.setFixedWidth(34) + pill.setAlignment(Qt.AlignmentFlag.AlignCenter) + pair = ("rose_dim", "rose_text") if severe else ("amber_dim", "amber_text") + pill.setStyleSheet( + f"background: {TECH_BLUE[pair[0]]}; color: {TECH_BLUE[pair[1]]};" + f" border: 1px solid {TECH_BLUE[pair[1]]}; border-radius: 10px; padding: 2px 0; font-size: 9.6px;") + layout.addWidget(pill, 0, Qt.AlignmentFlag.AlignTop) + block = QVBoxLayout() + block.setSpacing(1) + title = QLabel(text, row) + title.setTextFormat(Qt.TextFormat.PlainText) + title.setWordWrap(True) + title.setStyleSheet(f"color: {TECH_BLUE['heading']}; font-size: 12.3px;") + block.addWidget(title) + note = QLabel(attribution or "—", row) + note.setTextFormat(Qt.TextFormat.PlainText) + note.setWordWrap(True) + note.setStyleSheet(f"color: {TECH_BLUE['faint']}; font-size: 10.5px;") + block.addWidget(note) + layout.addLayout(block, 1) + badge = QLabel(str(count), row) + badge.setTextFormat(Qt.TextFormat.PlainText) + badge.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + badge.setFixedWidth(28) + badge.setFont(num_font(13)) + badge.setStyleSheet(f"color: {TECH_BLUE['muted']};") + layout.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop) + return row + + +def checklist_items(batch: dict) -> list[tuple[str, str, str]]: + """Model-reported risks and gaps first, then the system's own source gaps.""" + + data = _mapping(batch) + models = _mapping(data.get("models")) + collected: dict[str, tuple[list[str], str]] = {} + + def add(text: str, name: str, tone: str) -> None: + if not text: + return + names, previous = collected.get(text, ([], tone)) + if name and name not in names: + names.append(name) + collected[text] = (names, previous if _rank(previous) <= _rank(tone) else tone) + + for key, name in MODEL_NAMES.items(): + report = _mapping(_mapping(models.get(key)).get("report")) + risks = report.get("risk_assessment") + if isinstance(risks, list): + for risk in risks: + value = _mapping(risk) + level = _text(value.get("level")).lower() + tone = DANGER if level in {"high", "critical", "severe", "高"} else WARN + add(_text(value.get("label")), name, tone) + missing = report.get("missing_information") + if isinstance(missing, list): + for value in missing: + add(_reason(value), name, WARN) + gaps = data.get("missing") + if isinstance(gaps, list): + for gap in gaps: + value = _mapping(gap) + add(_reason(value.get("code") or value), "", DANGER if value.get("critical") else MUTED_DOT) + order = {DANGER: 0, WARN: 1, MUTED_DOT: 2} + items = [(text, "、".join(names) + (" 关键" if tone == DANGER else ""), tone) + for text, (names, tone) in collected.items()] + items.sort(key=lambda item: order.get(item[2], 3)) + return items + + +def _rank(tone: str) -> int: + return {DANGER: 0, WARN: 1, MUTED_DOT: 2}.get(tone, 3) + + +class PrescriptionReviewWorkspace(QWidget): + """The overview page: what this batch concluded, who used what, and what needs a decision.""" + + open_report = Signal(str) + summary_changed = Signal(str) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("PrescriptionReviewWorkspace") + self.setMinimumWidth(0) + self._batch: dict[str, Any] = {} + self.rows: list[ReviewDoseRow] = [] + self._review_widget: QWidget | None = None + self._summary = "选择报告后查看诊断与药方" + self.setStyleSheet(f""" + QWidget#PrescriptionReviewWorkspace {{ background: transparent; }} + {card_qss()} + QWidget#PrescriptionReviewWorkspace QLabel {{ color: {TECH_BLUE['text']}; + font-family: '{body_family()}'; font-size: 13px; background: transparent; }} + QWidget#PrescriptionReviewWorkspace QScrollArea {{ border: none; background: transparent; }} + """) + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + self.page_scroll = QScrollArea(self) + self.page_scroll.setWidgetResizable(True) + self.page_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.page_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + page = QWidget(self.page_scroll) + self.page_scroll.setWidget(page) + outer.addWidget(self.page_scroll) + root = QVBoxLayout(page) + root.setContentsMargins(0, 0, 6, 0) + root.setSpacing(14) + + self.first = QGridLayout() + self.first.setSpacing(14) + self.conclusions = ConclusionsPanel(page) + self.attribution = AttributionPanel(page) + self.checklist = ChecklistPanel(page) + self.checklist.open_details.connect(lambda: self.open_report.emit("sources")) + for card in (self.conclusions, self.attribution, self.checklist): + card.setMinimumHeight(318) + root.addLayout(self.first, 5) + + self.second = QGridLayout() + self.second.setSpacing(14) + self.doses = DoseDiffPanel(page) + self.doses.setMinimumHeight(256) + self.risks = RiskPanel(page) + self.risks.setMinimumHeight(256) + root.addLayout(self.second, 4) + self._columns = 0 + self._reflow(3) + + links = QHBoxLayout() + links.setSpacing(9) + self.links: dict[str, QPushButton] = {} + for text, target in (("查看完整报告", "report"), ("查看候选与逐味", "comparison"), + ("查看资料与缺口", "sources"), ("查看处理进度", "progress")): + button = self._link(f"{text} ›", target, page) + self.links[target] = button + links.addWidget(button) + links.addStretch(1) + root.addLayout(links) + self._render() + + # The design collapses its card grid to one column below 1240px; the workspace sits inside + # the window's rail and gutters, so the same breakpoint lands here. + STACK_BELOW = 960 + + def _reflow(self, columns: int) -> None: + """Lay the two card rows out in three columns, or stacked when the window is narrow.""" + + if columns == self._columns: + return + self._columns = columns + for layout in (self.first, self.second): + while layout.count(): + layout.takeAt(0) + for index in range(layout.columnCount()): + layout.setColumnStretch(index, 0) + cards = (self.conclusions, self.attribution, self.checklist) + if columns == 1: + for row, card in enumerate(cards): + self.first.addWidget(card, row, 0) + self.first.setColumnStretch(0, 1) + self.second.addWidget(self.doses, 0, 0) + self.second.addWidget(self.risks, 1, 0) + self.second.setColumnStretch(0, 1) + return + for column, card in enumerate(cards): + self.first.addWidget(card, 0, column) + self.first.setColumnStretch(column, 1) + self.second.addWidget(self.doses, 0, 0) + self.second.addWidget(self.risks, 0, 1) + self.second.setColumnStretch(0, 2) + self.second.setColumnStretch(1, 1) + + def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + super().resizeEvent(event) + self._reflow(1 if self.width() < self.STACK_BELOW else 3) + + def minimumSizeHint(self) -> QSize: + return QSize(360, 420) + + @property + def summary_text(self) -> str: + return self._summary + + def _link(self, text: str, target: str, parent: QWidget) -> QPushButton: + button = QPushButton(text, parent) + button.setObjectName("AiQuickLink") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setStyleSheet( + f"QPushButton#AiQuickLink {{ background: {TECH_BLUE['surface']}; color: {TECH_BLUE['muted']};" + f" border: 1px solid {TECH_BLUE['line']}; border-radius: 18px; padding: 8px 15px;" + " font-size: 11.55px; }" + f"QPushButton#AiQuickLink:hover {{ color: {TECH_BLUE['accent_text']};" + f" border-color: {TECH_BLUE['accent']}; }}") + button.clicked.connect(lambda _checked=False: self.open_report.emit(target)) + return button + + def set_review_selector(self, widget: QWidget) -> None: + """The model being annotated is chosen on the checklist card's own header line.""" + + widget.setParent(self.checklist.model_slot) + self.checklist.model_slot.layout().addWidget(widget) + widget.show() + + def set_review_widget(self, widget: QWidget) -> None: + if widget is self._review_widget: + return + if self._review_widget is not None: + self.checklist.review_layout.removeWidget(self._review_widget) + self._review_widget.hide() + self._review_widget = widget + widget.setParent(self.checklist.review_slot) + widget.setMinimumWidth(0) + widget.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.checklist.review_layout.addWidget(widget) + widget.show() + + def difference_count(self) -> int: + """Herbs where at least one model's dose differs from the doctor's.""" + + return sum(1 for delta in self.doses.rows + if any(value not in (None, 0) for value in delta.deltas.values())) + + def consensus_count(self) -> int: + """Herbs the doctor and both candidates all used.""" + + sets = herb_sets(self._batch) + return len(sets["doctor"] & sets["qwen"] & sets["openai"]) + + @staticmethod + def gap_counts(batch: dict) -> dict[str, int]: + return gap_counts(batch) + + @staticmethod + def restricted_count(batch: dict) -> int: + return restricted_attachments(batch) + + def set_batch(self, batch: dict) -> None: + data = _mapping(batch) + if data == self._batch: + return + self._batch = deepcopy(data) + self._render() + + def _render(self) -> None: + self.rows, _states = review_rows(self._batch) + deltas = dose_deltas(self.rows) + self.doses.set_rows(deltas) + self.risks.set_rows(deltas) + self.attribution.apply(self._batch) + self.conclusions.set_items(conclusions(self._batch, deltas)) + self.checklist.set_items(checklist_items(self._batch)) + differences = self.difference_count() + self._summary = (f"本批次有 {differences} 项剂量差异,请在复核清单中逐条确认。" + if deltas else "候选方案尚未生成可比剂量;请结合诊断依据与资料缺口复核。") + if self._batch.get("validity") not in {None, "current", "valid"}: + self._summary = "这是历史或失效报告,仅展示已保存记录,不再更新。" + if not self._batch: + self._summary = "选择报告后查看诊断与药方。" + self.summary_changed.emit(self._summary) diff --git a/app/tests/test_issued_prescription_ai.py b/app/tests/test_issued_prescription_ai.py index 0736fb8f1..b4eaa55eb 100644 --- a/app/tests/test_issued_prescription_ai.py +++ b/app/tests/test_issued_prescription_ai.py @@ -11,8 +11,8 @@ from uuid import UUID os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QApplication, QPushButton +from PySide6.QtCore import QEvent, QObject, QRect, Qt +from PySide6.QtWidgets import QApplication, QPushButton, QWidget from doctor_workstation.services import DemoDoctorRepository from doctor_workstation.services.repository import RemoteDoctorRepository @@ -138,6 +138,215 @@ def test_shared_report_reads_only_and_renders_each_model_independently(applicati assert repository.calls == calls +def test_report_open_and_poll_never_show_auxiliary_windows(application: QApplication, immediate: None) -> None: + shown_windows = [] + + class WindowObserver(QObject): + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if event.type() == QEvent.Type.Show and isinstance(watched, QWidget) and watched.isWindow(): + shown_windows.append((watched.metaObject().className(), watched.windowTitle())) + return False + + observer = WindowObserver() + application.installEventFilter(observer) + dialog = None + try: + repository = Repository() + dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) + dialog.show() + application.processEvents() + initial_windows = list(shown_windows) + shown_windows.clear() + dialog.tabs.setCurrentIndex(3) + dialog.model_views["qwen"]["comment"].setPlainText("正在填写的复核意见") + for _ in range(3): + dialog._timer.timeout.emit() + dialog._progress_timer.timeout.emit() + application.processEvents() + assert len([call for call in repository.calls if call[0] == "detail"]) == 4 + assert shown_windows == [], "Polling must not show even transient top-level widgets" + assert initial_windows == [("IssuedPrescriptionAiDialog", dialog.windowTitle())] + assert dialog.tabs.currentIndex() == 3 + assert dialog.model_views["qwen"]["comment"].toPlainText() == "正在填写的复核意见" + assert dialog.chip_row.count() == 2 + assert all(dialog.chip_row.itemAt(index).widget().isVisible() for index in range(2)) + assert dialog._timer.isActive() and dialog._progress_timer.isActive() + finally: + application.removeEventFilter(observer) + if dialog is not None: + dialog.close() + + +def test_navigation_names_every_destination_once(application: QApplication, immediate: None) -> None: + """A duplicated tab name makes the navigation unreadable; the live progress lives in the one tab.""" + dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801) + dialog.show() + application.processEvents() + names = [dialog.tabs.tabText(index) for index in range(dialog.tabs.count())] + assert len(names) == len(set(names)), names + assert names.count("处理进度") == 1 + # Those pages are hosted in a scroll area, so the tab holds the host, not the page itself. + for key in ("per_herb", "gaps", "history"): + assert dialog.tabs.indexOf(dialog.tab_pages[key]) >= 0 + bar = dialog.model_views["qwen"]["progress_bar"] + assert dialog.pipeline_page.isAncestorOf(bar) + assert dialog.pipeline_page.stage_labels["qwen"].isHidden() + dialog.close() + + +@pytest.mark.parametrize(("width", "height"), [(1280, 860), (1024, 700), (940, 640)]) +def test_prescription_workspace_is_the_primary_view(application: QApplication, immediate: None, width: int, height: int) -> None: + dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801) + dialog.resize(width, height) + dialog.show() + application.processEvents() + assert (dialog.width(), dialog.height()) == (width, height) + assert dialog.tabs.currentWidget() is dialog.comparison_panel + assert dialog.tabs.tabText(0) == "对比总览" + assert dialog.comparison_panel.isVisible() + assert dialog.tabs.height() > height * 0.28 + assert dialog.model_views["qwen"]["comment"].isVisible() + # The chart strip is on the first screen but must never outgrow its budget, and a model + # without a comparable score shows no filled bar. + assert dialog.model_views["qwen"]["agreement_bar"].value() == 0 + assert dialog.model_views["openai"]["agreement_bar"].isHidden() + assert dialog.model_views["qwen"]["gauge"].accessibleDescription() == "0.0%" + assert dialog.model_views["openai"]["gauge"].accessibleDescription() == "暂无可比结果" + dialog.close() + + +@pytest.mark.parametrize(("width", "height"), [(1440, 940), (1280, 860), (1024, 700), (940, 640)]) +def test_review_controls_remain_inside_the_sidebar(application: QApplication, immediate: None, + width: int, height: int) -> None: + repository = Repository() + model = repository.batches[0]["models"]["qwen"] + herb = model["candidate"]["herbs"][0] + model["comparison"]["rows"][0].update(key="黄芪", doctor={**herb, "dosage": 16}, candidate=herb) + dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) + dialog.resize(width, height) + dialog.show() + panel = dialog.comparison_panel + for _ in range(4): + application.processEvents() + assert dialog.nav_buttons["overview"].isChecked() + for key in ai.MODELS: + dialog.review_model.setCurrentIndex(0 if key == "qwen" else 1) + application.processEvents() + views = dialog.model_views[key] + for control in (dialog.review_model, views["review_state"], views["comment"], views["save"]): + assert control.isVisible() + bounds = QRect(control.mapTo(panel.checklist, control.rect().topLeft()), control.size()) + assert panel.checklist.rect().contains(bounds), (key, control.accessibleName(), bounds) + assert ai.MODELS[key] in dialog.save_review_button.toolTip() + assert panel.checklist.isVisible() + dialog.close() + + +def test_hero_band_carries_the_frozen_identity_and_yields_on_short_windows(application: QApplication, immediate: None) -> None: + repository = Repository() + repository.batches[0]["doctor_snapshot"] = { + "patient": {"name": "测试患者", "gender_label": "女", "age": 58}, + "diagnosis": {"clinical_diagnosis": "消渴病 气阴两虚"}, + "prescription": {"prescription_type": "浓缩水丸", "dose_count": 1, "dose_unit": "剂"}, + } + dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) + dialog.resize(1280, 860) + dialog.show() + application.processEvents() + assert "测试患者 · 女 · 58 岁" in dialog.identity.text() + assert "消渴病 气阴两虚" in dialog.identity.text() + assert "浓缩水丸 · 1 剂" in dialog.identity.text() + assert dialog.fact_values["basis"].text() == "同单位 · 每剂" + assert dialog.fact_values["prescription"].text() == "801 / 501" + dialog.resize(1024, 700) + application.processEvents() + assert dialog.fact_values["batch"].text() == "#40" + dialog.close() + + +def test_hero_band_shows_dashes_when_the_snapshot_is_not_deployed(application: QApplication, immediate: None) -> None: + repository = Repository() + repository.batches[0].pop("doctor_snapshot", None) + dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) + dialog.resize(1280, 860) + dialog.show() + application.processEvents() + assert dialog.identity.text() == "尚无冻结快照" + assert dialog.fact_values["prescription"].text().startswith("801") + dialog.close() + + +def test_failed_model_states_the_reason_and_offers_its_own_retry(application: QApplication, immediate: None) -> None: + repository = Repository() + repository.batches[0]["models"]["openai"].update(status="failed", error_code="INVALID_REPORT_OUTPUT", + error_message="INVALID_REPORT_OUTPUT") + dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) + dialog.show() + application.processEvents() + assert dialog.model_views["openai"]["failure"].isVisible() + text = dialog.model_views["openai"]["failure_text"].text() + assert "OpenAI 失败" in text and "格式校验" in text and "原资料快照" in text + assert "INVALID_REPORT_OUTPUT" not in text + assert dialog.model_views["openai"]["retry"].isEnabled() + assert dialog.model_views["openai"]["retry"].isVisible() + # A model that is still running never shows a failure row. + dialog.model_views["openai"]["retry"].click() + application.processEvents() + assert ("retry", (40, "openai")) in repository.calls + dialog.close() + + +def test_exhausted_manual_retries_point_at_regeneration_instead_of_retry(application: QApplication, immediate: None) -> None: + repository = Repository() + repository.batches[0]["models"]["openai"].update(status="failed", error_code="UPSTREAM_TIMEOUT", + error_message="UPSTREAM_TIMEOUT", manual_retries=2) + dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) + dialog.show() + application.processEvents() + text = dialog.model_views["openai"]["failure_text"].text() + assert "手动重试次数已用完" in text and "重新分析" in text + assert not dialog.model_views["openai"]["retry"].isVisible() + dialog.close() + + +def test_inline_review_keeps_model_drafts_and_saves_selected_model(application: QApplication, immediate: None) -> None: + repository = Repository() + repository.batches[0]["models"]["openai"].update(report_id=92, report={"summary": "已保存报告"}) + dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) + dialog.show() + application.processEvents() + dialog.model_views["qwen"]["comment"].setPlainText("千问复核草稿") + dialog.review_model.setCurrentIndex(1) + dialog.model_views["openai"]["comment"].setPlainText("OpenAI复核草稿") + dialog.model_views["openai"]["review_state"].setCurrentIndex(3) + dialog._poll() + application.processEvents() + assert dialog.review_model.currentData() == "openai" + assert dialog.model_views["qwen"]["comment"].toPlainText() == "千问复核草稿" + assert dialog.model_views["openai"]["comment"].toPlainText() == "OpenAI复核草稿" + dialog.save_review_button.click() + assert ("review", (40, "openai", "reviewed", "OpenAI复核草稿")) in repository.calls + assert not any(call[0] == "review" and call[1][1] == "qwen" for call in repository.calls) + dialog.close() + + +def test_workspace_links_reach_existing_supporting_reports(application: QApplication, immediate: None) -> None: + dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801) + dialog.show() + # Each link opens the composed page that answers it, not the raw saved text. + for field, key in (("report", "report"), ("candidate", "candidate"), ("comparison", "per_herb"), + ("sources", "gaps"), ("progress", "pipeline"), ("doctor", "original")): + dialog.comparison_panel.open_report.emit(field) + assert dialog.tabs.currentWidget() is dialog.tab_pages[key], field + dialog.nav_buttons["overview"].click() + assert dialog.tabs.currentWidget() is dialog.comparison_panel + assert dialog.nav_buttons["overview"].isChecked() + assert not dialog.nav_buttons["gaps"].isChecked() + dialog.nav_buttons["per_herb"].click() + assert dialog.tabs.currentWidget() is dialog.tab_pages["per_herb"] + dialog.close() + + def test_retry_and_review_target_only_selected_model(application: QApplication, immediate: None) -> None: repository = Repository() repository.batches[0]["status"] = "partial" @@ -681,6 +890,25 @@ def test_metadata_and_prose_remain_html_escaped(application: QApplication) -> No browser.close() +def test_candidate_reading_view_keeps_full_medicine_instructions(application: QApplication) -> None: + candidate = { + "prescription_name": "测试候选方", "dose_basis": "per_dose", "unit": "g", + "herbs": [{"name": "测试药材", "dosage": 12, "formula_type": "主方", + "instructions": "先煎,具体时长由医生复核", "processing": "生品", + "evidence_references": ["diagnoses:501"]}], + "rationale": "保留完整方义", "risk_warnings": ["复核提示 "], + } + html = ai._candidate_html(candidate) + assert html.index("测试药材") < html.index("保留完整方义") + assert "