feat: add scoped read-only audio preview and isolated Dify transport

This commit is contained in:
2026-10-09 16:10:59 +08:00
parent 27772bec61
commit 4d0af7f3f4
36 changed files with 1517 additions and 68 deletions
+5
View File
@@ -23,6 +23,10 @@ export interface FollowupTranscriptSegment {
export interface FollowupCapabilities { export interface FollowupCapabilities {
enabled: boolean enabled: boolean
audio_verified: boolean audio_verified: boolean
preview_only?: boolean
preview_ready?: boolean
can_review?: boolean
test_scope_allowed?: boolean
can_upload: boolean can_upload: boolean
can_apply: boolean can_apply: boolean
can_daily: boolean can_daily: boolean
@@ -71,6 +75,7 @@ export interface FollowupTaskSummary {
expires_at: number | string expires_at: number | string
can_retry: boolean can_retry: boolean
audio_available: boolean audio_available: boolean
preview_only?: boolean
transcript_available?: boolean transcript_available?: boolean
pipeline_progress?: { transcribed: number; total: number } pipeline_progress?: { transcribed: number; total: number }
} }
@@ -1,5 +1,5 @@
<template> <template>
<section v-if="capabilities.enabled" class="followup-panel" aria-label="回访录音"> <section v-if="featureEnabled" class="followup-panel" aria-label="回访录音">
<div class="panel-toolbar"> <div class="panel-toolbar">
<div class="toolbar-title"> <div class="toolbar-title">
<el-button v-if="selectedId" link type="primary" :disabled="busy" @click="backToList">返回列表</el-button> <el-button v-if="selectedId" link type="primary" :disabled="busy" @click="backToList">返回列表</el-button>
@@ -12,13 +12,15 @@
</div> </div>
</div> </div>
<div v-if="helpVisible" class="help-box"> <div v-if="helpVisible" class="help-box">
<p>录音只生成建议,不会自动写入。逐项核对后选择采纳,再确认写入;日常记录按日期和时段分别保留。</p> <p v-if="previewOnly">当前仅开放测试识别与提炼,可核对并保存审核草稿;测试预览任务不提供病历、日常记录或备注的确认写入。</p>
<p v-else>录音只生成建议,不会自动写入。逐项核对后选择采纳,再确认写入;日常记录按日期和时段分别保留。</p>
<p>仅当前可见标签页每 5 秒刷新;已编辑内容不会被覆盖。后台分析不受关闭页面影响,未知结果请先核对任务或账单,勿重复上传。</p> <p>仅当前可见标签页每 5 秒刷新;已编辑内容不会被覆盖。后台分析不受关闭页面影响,未知结果请先核对任务或账单,勿重复上传。</p>
<p v-if="detail">任务 #{{ detail.id }} · 版本 {{ detail.version }} · 保留至 {{ formatExpiry(detail.expires_at) }}</p> <p v-if="detail">任务 #{{ detail.id }} · 版本 {{ detail.version }} · 保留至 {{ formatExpiry(detail.expires_at) }}</p>
</div> </div>
<el-alert v-if="!capabilities.audio_verified" type="warning" :closable="false" title="当前环境尚未通过音频能力验证,暂不可上传或重试。已有任务可查看。" /> <el-alert v-if="previewOnly" type="warning" :closable="false" title="仅测试识别与提炼,不写入病例" description="可保存审核草稿;预览就绪不代表完整音频或准确度验收通过,不允许确认写入病历、日常记录或备注。" show-icon />
<el-alert v-if="!audioReady" type="warning" :closable="false" :title="capabilities.preview_only ? '测试预览尚未就绪,暂不可上传或重试。已有任务可查看。' : '当前环境尚未通过音频能力验证,暂不可上传或重试。已有任务可查看。'" />
<el-alert v-else-if="capabilities.can_upload && !capabilities.models.length" type="warning" :closable="false" title="暂无可用的分析模型,暂不可上传。已有任务可查看。" /> <el-alert v-else-if="capabilities.can_upload && !capabilities.models.length" type="warning" :closable="false" title="暂无可用的分析模型,暂不可上传。已有任务可查看。" />
<el-alert v-if="basicDirty" type="warning" :closable="false" title="病历有未保存修改,请先保存病历,再写入录音建议。" /> <el-alert v-if="basicDirty" type="warning" :closable="false" :title="previewOnly ? '病历有未保存修改,测试预览不会覆盖这些修改。' : '病历有未保存修改,请先保存病历,再写入录音建议。'" />
<el-alert v-if="createUncertain" type="warning" :closable="false" title="创建结果未确认,已停止重复提交。请刷新列表并联系管理员核对,不要重新上传同一录音。" /> <el-alert v-if="createUncertain" type="warning" :closable="false" title="创建结果未确认,已停止重复提交。请刷新列表并联系管理员核对,不要重新上传同一录音。" />
<el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon /> <el-alert v-if="errorMessage" type="error" :closable="false" :title="errorMessage" show-icon />
<div v-if="!selectedId" class="task-list"> <div v-if="!selectedId" class="task-list">
@@ -86,11 +88,11 @@
<div class="selection-summary"><strong>已选 {{ selectedCount }} 项</strong><span>{{ dirty ? '有未保存修改' : '草稿已同步' }}</span></div> <div class="selection-summary"><strong>已选 {{ selectedCount }} 项</strong><span>{{ dirty ? '有未保存修改' : '草稿已同步' }}</span></div>
<div v-if="canEditReview" class="action-buttons"> <div v-if="canEditReview" class="action-buttons">
<el-button :disabled="busy" :loading="mutation === 'save'" @click="saveDraft">保存草稿</el-button> <el-button :disabled="busy" :loading="mutation === 'save'" @click="saveDraft">保存草稿</el-button>
<el-button type="primary" :disabled="busy || basicDirty || !selectedCount || !!applyIssue" :loading="mutation === 'apply'" @click="applySelected">确认写入</el-button> <el-button v-if="canApplyReview" type="primary" :disabled="busy || basicDirty || !selectedCount || !!applyIssue" :loading="mutation === 'apply'" @click="applySelected">确认写入</el-button>
</div> </div>
<p v-if="applyIssue" class="review-error" role="alert">{{ applyIssue }}</p> <p v-if="applyIssue" class="review-error" role="alert">{{ applyIssue }}</p>
</div> </div>
<p v-if="detail.status === 'applied'" class="applied-note">本次审核已完成,所选项已写入诊单或日常记录,不能重复写入。</p> <p v-if="detail.status === 'applied'" class="applied-note">{{ previewOnly ? '该任务已有历史采纳记录;当前为测试预览,不允许再次写入。' : '本次审核已完成,所选项已写入诊单或日常记录,不能重复写入。' }}</p>
</section> </section>
<el-dialog v-model="uploadVisible" title="上传回访录音" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" :close-on-press-escape="!uploading" :show-close="!uploading" :before-close="closeUpload"> <el-dialog v-model="uploadVisible" title="上传回访录音" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" :close-on-press-escape="!uploading" :show-close="!uploading" :before-close="closeUpload">
<el-form label-position="top" class="upload-form"> <el-form label-position="top" class="upload-form">
@@ -109,7 +111,7 @@
<el-alert v-if="createUncertain && !uploading" type="warning" :closable="false" title="创建结果未确认,请关闭弹窗后刷新任务列表并联系管理员核对,不要重新上传。" /> <el-alert v-if="createUncertain && !uploading" type="warning" :closable="false" title="创建结果未确认,请关闭弹窗后刷新任务列表并联系管理员核对,不要重新上传。" />
<el-progress v-if="uploading" :percentage="uploadProgress" /> <el-progress v-if="uploading" :percentage="uploadProgress" />
<p v-if="uploadStage" class="upload-stage" role="status">{{ uploadStage }}</p> <p v-if="uploadStage" class="upload-stage" role="status">{{ uploadStage }}</p>
<p class="muted">分析仅生成建议,人工确认后才会写入。</p> <p class="muted">{{ previewOnly ? '本次仅测试识别与提炼,可保存审核草稿,不允许写入病历。' : '分析仅生成建议,人工确认后才会写入。' }}</p>
<template #footer><el-button :disabled="uploading" @click="closeUpload()">取消</el-button><el-button type="primary" :disabled="!canUpload || busy || createUncertain" :loading="uploading" @click="submitUpload">上传并分析</el-button></template> <template #footer><el-button :disabled="uploading" @click="closeUpload()">取消</el-button><el-button type="primary" :disabled="!canUpload || busy || createUncertain" :loading="uploading" @click="submitUpload">上传并分析</el-button></template>
</el-dialog> </el-dialog>
</section> </section>
@@ -124,7 +126,7 @@ import {
type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary
} from '@/api/followupAudio' } from '@/api/followupAudio'
import FollowupAudioReview from './FollowupAudioReview.vue' import FollowupAudioReview from './FollowupAudioReview.vue'
import { audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupChannelLabel, followupError, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState' import { audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupAudioReady, followupCanApply, followupCanReview, followupChannelLabel, followupError, followupFeatureEnabled, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
import { createFollowupAudioPlayback, type FollowupPlaybackChannel } from './followupAudioPlayback' import { createFollowupAudioPlayback, type FollowupPlaybackChannel } from './followupAudioPlayback'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
diagnosisId: number; viewOnly?: boolean; active?: boolean; capabilities: FollowupCapabilities; diagnosisId: number; viewOnly?: boolean; active?: boolean; capabilities: FollowupCapabilities;
@@ -166,16 +168,21 @@ const channelLoading = ref(false)
const playbackError = ref('') const playbackError = ref('')
const documentVisible = ref(typeof document === 'undefined' || document.visibilityState !== 'hidden') const documentVisible = ref(typeof document === 'undefined' || document.visibilityState !== 'hidden')
const busy = computed(() => uploading.value || !!mutation.value) const busy = computed(() => uploading.value || !!mutation.value)
const canUpload = computed(() => !props.viewOnly && props.capabilities.enabled && props.capabilities.audio_verified && props.capabilities.can_upload && props.capabilities.models.length > 0) const featureEnabled = computed(() => followupFeatureEnabled(props.capabilities))
const canEditReview = computed(() => !props.viewOnly && props.capabilities.enabled && props.capabilities.can_apply && detail.value?.status === 'review') const previewOnly = computed(() => !!props.capabilities.preview_only || !!detail.value?.preview_only)
const audioReady = computed(() => followupAudioReady(props.capabilities))
const canUpload = computed(() => !props.viewOnly && featureEnabled.value && audioReady.value && props.capabilities.can_upload && props.capabilities.models.length > 0)
const canEditReview = computed(() => !props.viewOnly && followupCanReview(props.capabilities, !!detail.value?.preview_only) && detail.value?.status === 'review')
const canApplyReview = computed(() => !props.viewOnly && followupCanApply(props.capabilities, !!detail.value?.preview_only) && detail.value?.status === 'review')
const selectedCount = computed(() => reviewItems.value.filter(item => item.selected).length) const selectedCount = computed(() => reviewItems.value.filter(item => item.selected).length)
const pendingCount = computed(() => reviewItems.value.filter(item => reviewPending(item, detail.value?.fields || props.capabilities.fields, props.capabilities.can_daily)).length + (detail.value?.uncertainties.length || 0)) const pendingCount = computed(() => reviewItems.value.filter(item => reviewPending(item, detail.value?.fields || props.capabilities.fields, props.capabilities.can_daily)).length + (detail.value?.uncertainties.length || 0))
const applyIssue = computed(() => roleDirty.value ? '声道角色标注尚未保存,请先保存草稿并重新核对建议' const applyIssue = computed(() => previewOnly.value ? '测试预览仅保存审核草稿,不允许确认写入病历'
: roleDirty.value ? '声道角色标注尚未保存,请先保存草稿并重新核对建议'
: hasStereo.value && savedChannelRoles.value === 'unconfirmed' ? '请先回听并确认声道角色,保存草稿后重新核对建议' : hasStereo.value && savedChannelRoles.value === 'unconfirmed' ? '请先回听并确认声道角色,保存草稿后重新核对建议'
: changedReviewTarget(reviewItems.value, detail.value?.items || []) : changedReviewTarget(reviewItems.value, detail.value?.items || [])
? '写入目标或记录日期已更改,请先保存审核草稿取得新快照,再核对并采纳' ? '写入目标或记录日期已更改,请先保存审核草稿取得新快照,再核对并采纳'
: reviewItems.value.map(item => reviewFieldIssue(item, detail.value?.fields || props.capabilities.fields) || reviewIssue(item, props.capabilities.can_daily)).find(Boolean) || '') : reviewItems.value.map(item => reviewFieldIssue(item, detail.value?.fields || props.capabilities.fields) || reviewIssue(item, props.capabilities.can_daily)).find(Boolean) || '')
const effectiveActive = computed(() => props.active && props.capabilities.enabled && documentVisible.value) const effectiveActive = computed(() => props.active && featureEnabled.value && documentVisible.value)
const scope = createFollowupScope() const scope = createFollowupScope()
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let pollRunning = false let pollRunning = false
@@ -219,7 +226,7 @@ async function readDetail(id: number, preserve = false) {
if (!scope.current(ticket) || id !== selectedId.value || (detail.value && fresh.version < detail.value.version)) return if (!scope.current(ticket) || id !== selectedId.value || (detail.value && fresh.version < detail.value.version)) return
const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied' const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied'
installDetail(fresh, preserve) installDetail(fresh, preserve)
if (newlyApplied) emit('applied', props.diagnosisId) if (newlyApplied && !previewOnly.value) emit('applied', props.diagnosisId)
if (preserve) errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。请重新核对当前值后选择采纳。' if (preserve) errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。请重新核对当前值后选择采纳。'
} }
async function refresh() { async function refresh() {
@@ -251,7 +258,7 @@ async function confirmLeave(): Promise<boolean> {
} catch { return false } } catch { return false }
} }
async function openTask(id: number) { async function openTask(id: number) {
if (id === selectedId.value || busy.value) return if (!featureEnabled.value || id === selectedId.value || busy.value) return
const previous = scope.capture() const previous = scope.capture()
if (!await confirmLeave() || !scope.current(previous)) return if (!await confirmLeave() || !scope.current(previous)) return
stop(); resetRoles(); selectedId.value = id; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; errorMessage.value = ''; detailSections.value = [] stop(); resetRoles(); selectedId.value = id; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; errorMessage.value = ''; detailSections.value = []
@@ -286,22 +293,22 @@ async function submitUpload() {
try { try {
uploadStage.value = '正在创建私有上传会话' uploadStage.value = '正在创建私有上传会话'
const session = await followupAudioUploadSession({ diagnosis_id: props.diagnosisId, file_name: recording.name, total_bytes: recording.size }, ticket.signal) const session = await followupAudioUploadSession({ diagnosis_id: props.diagnosisId, file_name: recording.name, total_bytes: recording.size }, ticket.signal)
if (!scope.current(ticket)) return if (!scope.current(ticket) || !canUpload.value) return
uploadStage.value = '正在上传录音分片' uploadStage.value = '正在上传录音分片'
const finished = await uploadChunks(recording, session.chunk_bytes, const finished = await uploadChunks(recording, session.chunk_bytes,
(index, chunk) => followupAudioUploadChunk(session.upload_id, index, chunk, ticket.signal), (index, chunk) => followupAudioUploadChunk(session.upload_id, index, chunk, ticket.signal),
() => scope.current(ticket), percent => { uploadProgress.value = percent }) () => scope.current(ticket) && canUpload.value, percent => { uploadProgress.value = percent })
if (!finished) return if (!finished) return
uploadStage.value = '正在校验录音时长和完整性' uploadStage.value = '正在校验录音时长和完整性'
await followupAudioUploadComplete(session.upload_id, ticket.signal) await followupAudioUploadComplete(session.upload_id, ticket.signal)
if (!scope.current(ticket)) return if (!scope.current(ticket) || !canUpload.value) return
uploadStage.value = '正在创建分析任务'; creating = true; createUncertain.value = true uploadStage.value = '正在创建分析任务'; creating = true; createUncertain.value = true
const result = await followupAudioCreate({ diagnosis_id: props.diagnosisId, upload_id: session.upload_id, recorded_at: recordedAt.value, model_key: modelKey.value }, ticket.signal) const result = await followupAudioCreate({ diagnosis_id: props.diagnosisId, upload_id: session.upload_id, recorded_at: recordedAt.value, model_key: modelKey.value }, ticket.signal)
if (!scope.current(ticket)) return if (!scope.current(ticket)) return
createUncertain.value = false; uploading.value = false; uploadVisible.value = false; file.value = null; if (fileInput.value) fileInput.value.value = '' createUncertain.value = false; uploading.value = false; uploadVisible.value = false; file.value = null; if (fileInput.value) fileInput.value.value = ''
uploadStage.value = result.reused uploadStage.value = result.reused
? result.reuse_message || '同一录音已有任务,已打开原任务,不会重复调用模型' ? result.reuse_message || '同一录音已有任务,已打开原任务,不会重复调用模型'
: '任务已创建,后台将进行分析;审核前不会写入业务记录' : props.capabilities.preview_only ? '测试任务已创建,后台将识别与提炼;此任务不允许写入业务记录' : '任务已创建,后台将进行分析;审核前不会写入业务记录'
if (result.reused) ElMessage.warning(uploadStage.value) if (result.reused) ElMessage.warning(uploadStage.value)
await openTask(result.task_id) await openTask(result.task_id)
} catch (error) { } catch (error) {
@@ -328,25 +335,25 @@ async function saveDraft() {
installDetail(saved, true, false) installDetail(saved, true, false)
errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消,请重新核对后保存或采纳。' errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消,请重新核对后保存或采纳。'
} else { } else {
installDetail(saved, false); ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : '审核草稿已保存,尚未写入病历') installDetail(saved, false); ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : previewOnly.value ? '测试审核草稿已保存,不会写入病历' : '审核草稿已保存,尚未写入病历')
} }
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) } } catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } } finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
} }
async function applySelected() { async function applySelected() {
if (!canEditReview.value || !detail.value || busy.value || !selectedCount.value) return if (!canApplyReview.value || !detail.value || busy.value || !selectedCount.value) return
if (reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return } if (reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return }
if (props.basicDirty || (props.beforeApply && !props.beforeApply())) { ElMessage.warning('请先保存病历表单,当前修改不会被覆盖'); return } if (props.basicDirty || (props.beforeApply && !props.beforeApply())) { ElMessage.warning('请先保存病历表单,当前修改不会被覆盖'); return }
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return } if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
const ticket = scope.capture() const ticket = scope.capture()
try { await ElMessageBox.confirm(`将一次性采纳 ${selectedCount.value} 项;任一项冲突则整批不写入。确认已核对原话、日期和时间?`, '确认采纳', { type: 'warning', confirmButtonText: '全部采纳', cancelButtonText: '返回核对' }) } catch { return } try { await ElMessageBox.confirm(`将一次性采纳 ${selectedCount.value} 项;任一项冲突则整批不写入。确认已核对原话、日期和时间?`, '确认采纳', { type: 'warning', confirmButtonText: '全部采纳', cancelButtonText: '返回核对' }) } catch { return }
if (!scope.current(ticket) || !canEditReview.value || !selectedCount.value || props.basicDirty || (props.beforeApply && !props.beforeApply()) || !detail.value) return if (!scope.current(ticket) || !canApplyReview.value || !selectedCount.value || props.basicDirty || (props.beforeApply && !props.beforeApply()) || !detail.value) return
if (applyIssue.value) { ElMessage.warning(applyIssue.value); return } if (applyIssue.value) { ElMessage.warning(applyIssue.value); return }
const diagnosisId = props.diagnosisId const diagnosisId = props.diagnosisId
mutation.value = 'apply'; errorMessage.value = ''; clearTimer() mutation.value = 'apply'; errorMessage.value = ''; clearTimer()
try { try {
await followupAudioApply({ id: detail.value.id, version: detail.value.version, items: cloneReviewItems(reviewItems.value) }, ticket.signal) await followupAudioApply({ id: detail.value.id, version: detail.value.version, items: cloneReviewItems(reviewItems.value) }, ticket.signal)
if (!scope.current(ticket)) return if (!scope.current(ticket) || !canApplyReview.value) return
detail.value = { ...detail.value, status: 'applied' }; snapshot.value = JSON.stringify(reviewItems.value) detail.value = { ...detail.value, status: 'applied' }; snapshot.value = JSON.stringify(reviewItems.value)
ElMessage.success('所选记录已全部采纳'); emit('applied', diagnosisId) ElMessage.success('所选记录已全部采纳'); emit('applied', diagnosisId)
await readDetail(selectedId.value) await readDetail(selectedId.value)
@@ -357,7 +364,7 @@ async function retryTask() {
if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return
const ticket = scope.capture() const ticket = scope.capture()
try { await ElMessageBox.confirm('仅对服务端已确认失败、允许重试的任务重新分析。确认继续?', '确认重试', { type: 'warning' }) } catch { return } try { await ElMessageBox.confirm('仅对服务端已确认失败、允许重试的任务重新分析。确认继续?', '确认重试', { type: 'warning' }) } catch { return }
if (!scope.current(ticket) || !detail.value) return if (!scope.current(ticket) || !detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value) return
mutation.value = 'retry'; clearTimer() mutation.value = 'retry'; clearTimer()
try { await followupAudioRetry(detail.value.id, ticket.signal); if (scope.current(ticket)) await readDetail(selectedId.value) } try { await followupAudioRetry(detail.value.id, ticket.signal); if (scope.current(ticket)) await readDetail(selectedId.value) }
catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) } catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
@@ -366,7 +373,7 @@ async function retryTask() {
async function reloadAudio() { releaseAudio(); await loadAudio() } async function reloadAudio() { releaseAudio(); await loadAudio() }
async function playChannel(channel: FollowupPlaybackChannel) { await loadAudio((audioElement.value?.currentTime || 0) * 1000, channel === 'original' ? undefined : channel) } async function playChannel(channel: FollowupPlaybackChannel) { await loadAudio((audioElement.value?.currentTime || 0) * 1000, channel === 'original' ? undefined : channel) }
async function loadAudio(milliseconds?: number, channel?: FollowupAudioChannel) { async function loadAudio(milliseconds?: number, channel?: FollowupAudioChannel) {
if (!detail.value?.audio_available) return if (!featureEnabled.value || !detail.value?.audio_available) return
const operation = ++playbackRequest const operation = ++playbackRequest
playbackError.value = '' playbackError.value = ''
if (channel !== undefined && (!hasStereo.value || (channel !== 0 && channel !== 1))) { if (channel !== undefined && (!hasStereo.value || (channel !== 0 && channel !== 1))) {
@@ -406,7 +413,7 @@ function formatExpiry(value: number | string) {
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
} }
function onVisibility() { documentVisible.value = document.visibilityState !== 'hidden' } function onVisibility() { documentVisible.value = document.visibilityState !== 'hidden' }
watch([() => props.diagnosisId, effectiveActive], ([id, active]) => { watch([() => props.diagnosisId, effectiveActive, () => !!props.capabilities.preview_only], ([id, active]) => {
stop() stop()
if (id !== lastDiagnosis) { if (id !== lastDiagnosis) {
resetRoles(); lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]' resetRoles(); lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'
@@ -107,8 +107,21 @@ export function rebaseReviewItems(local: FollowupReviewItem[], fresh: FollowupRe
time_period: edit.time_period, target_id: edit.target_id, selected: false, needs_review: true } : item time_period: edit.time_period, target_id: edit.target_id, selected: false, needs_review: true } : item
}) })
} }
/** Missing preview readiness/scope fails closed; normal deployments retain their full-verification rules. */
export function followupFeatureEnabled(caps: FollowupCapabilities | null): boolean {
return !!caps?.enabled && (!caps.preview_only || caps.test_scope_allowed === true)
}
export function followupAudioReady(caps: FollowupCapabilities | null): boolean {
return !!caps && (caps.preview_only ? caps.preview_ready === true && caps.test_scope_allowed === true : caps.audio_verified)
}
export function followupCanReview(caps: FollowupCapabilities | null, previewTask = false): boolean {
return followupFeatureEnabled(caps) && !!caps && (caps.preview_only || previewTask ? caps.can_review === true : caps.can_apply)
}
export function followupCanApply(caps: FollowupCapabilities | null, previewTask = false): boolean {
return followupFeatureEnabled(caps) && !!caps && !caps.preview_only && !previewTask && caps.can_apply
}
export function uploadIssue(file: Pick<File, 'size' | 'name'> | null, recordedAt: string, model: string, caps: FollowupCapabilities | null, viewOnly: boolean): string { export function uploadIssue(file: Pick<File, 'size' | 'name'> | null, recordedAt: string, model: string, caps: FollowupCapabilities | null, viewOnly: boolean): string {
if (viewOnly || !caps?.enabled || !caps.audio_verified || !caps.can_upload) return '当前不可上传录音' if (viewOnly || !caps || !followupFeatureEnabled(caps) || !followupAudioReady(caps) || !caps.can_upload) return '当前不可上传录音'
if (!file || !file.size) return '请选择非空录音文件' if (!file || !file.size) return '请选择非空录音文件'
if (file.size > caps.limits.max_bytes) return '录音文件超过大小限制' if (file.size > caps.limits.max_bytes) return '录音文件超过大小限制'
if (!/\.(mp3|wav|m4a|amr)$/i.test(file.name)) return '请选择支持的录音格式' if (!/\.(mp3|wav|m4a|amr)$/i.test(file.name)) return '请选择支持的录音格式'
+217
View File
@@ -1206,3 +1206,220 @@ test('task switch and observed audio expiry both close live stereo routing and r
f.dispose() f.dispose()
} }
}) })
const previewCaps = (extra = {}) => ({ ...caps(), audio_verified: false, preview_only: true, preview_ready: true, can_review: true, can_apply: false, test_scope_allowed: true, ...extra })
test('preview readiness is independent of full verification and scope/readiness omissions fail closed', () => {
const recording = { name: 'synthetic.wav', size: 100 }
assert.equal(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', previewCaps(), false), '')
assert.equal(state.followupCanReview(previewCaps()), true)
assert.equal(state.followupCanApply(previewCaps({ can_apply: true, audio_verified: true })), false)
for (const patch of [{ enabled: false }, { preview_ready: false }, { preview_ready: undefined }, { test_scope_allowed: false }, { test_scope_allowed: undefined }, { can_upload: false }]) {
assert.match(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', previewCaps({ audio_verified: true, ...patch }), false), /不可上传/)
}
assert.equal(state.followupCanReview(previewCaps({ can_review: false, can_apply: true })), false)
assert.equal(state.followupCanReview(previewCaps({ can_review: undefined, can_apply: true })), false)
assert.equal(state.followupFeatureEnabled(previewCaps({ test_scope_allowed: false })), false)
const normal = { ...caps(), preview_only: false, preview_ready: false, can_review: false, test_scope_allowed: false }
assert.equal(state.followupAudioReady(normal), true)
assert.equal(state.followupCanReview(normal), true)
assert.equal(state.followupCanApply(normal), true)
assert.match(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', { ...normal, audio_verified: false, preview_ready: true }, false), /不可上传/)
for (const helper of [state.followupFeatureEnabled, state.followupAudioReady, state.followupCanReview, state.followupCanApply]) assert.equal(helper(null), false)
})
test('preview actual component can upload, view transcript and save draft with full verification false but never apply', async () => {
const f = setupPanel({ followupAudioDetail: async id => task(id, { preview_only: true }), followupAudioSaveDraft: async payload => {
f.calls.push(['save', payload]); return task(payload.id, { preview_only: true, version: payload.version + 1, items: payload.items })
} }, { capabilities: previewCaps() })
await settle()
assert.equal(f.panel.canUpload.value, true)
f.panel.openUpload(); f.panel.file.value = new File(['synthetic'], 'test.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
await f.panel.submitUpload()
assert.ok(f.calls.some(call => call[0] === 'create'))
assert.equal(f.panel.detail.value.transcript, '测试原文')
assert.equal(f.props.capabilities.audio_verified, false)
assert.equal(f.panel.canEditReview.value, true)
assert.equal(f.panel.canApplyReview.value, false)
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
await f.panel.saveDraft(); await f.panel.applySelected()
assert.equal(f.calls.filter(call => call[0] === 'save').length, 1)
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
assert.deepEqual(f.events, [])
assert.match(f.panel.applyIssue.value, /测试预览/)
f.dispose()
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
assert.match(source, /v-if="previewOnly"[^>]+title="仅测试识别与提炼,不写入病例"/)
assert.match(source, /<el-button v-if="canApplyReview"[^>]+@click="applySelected">确认写入/)
assert.match(source, /预览就绪不代表完整音频或准确度验收通过/)
})
test('preview roles and review_refreshed drafts remain editable without can_apply and preserve dirty work', async () => {
const current = stereoTask({ preview_only: true })
const sent = []
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
sent.push(payload)
return { ...current, version: 2, channel_roles: payload.channel_roles, review_refreshed: true, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
} }, { capabilities: previewCaps() }, { confirm: async () => { throw new Error('cancel') } })
await settle(); await f.panel.openTask(1)
f.panel.setChannelRoles('left_service'); f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.2
assert.equal(f.panel.dirty.value, true)
assert.equal(await f.panel.confirmLeave(), false)
await f.panel.saveDraft()
assert.equal(sent[0].channel_roles, 'left_service')
assert.equal(sent[0].version, 1)
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 7.2)
assert.equal(f.panel.reviewItems.value[0].selected, false)
await f.panel.applySelected()
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
assert.deepEqual(f.events, [])
f.dispose()
})
test('preview retry uses preview readiness and rechecks readiness after confirmation', async () => {
const failed = task(1, { status: 'failed', preview_only: true, can_retry: true })
const f = setupPanel({ followupAudioDetail: async () => failed }, { capabilities: previewCaps() })
await settle(); await f.panel.openTask(1); await f.panel.retryTask()
assert.equal(f.calls.filter(call => call[0] === 'retry').length, 1)
f.props.capabilities = previewCaps({ preview_ready: false, audio_verified: true })
await f.panel.retryTask()
assert.equal(f.calls.filter(call => call[0] === 'retry').length, 1)
f.dispose()
const confirm = deferred()
const g = setupPanel({ followupAudioDetail: async () => failed }, { capabilities: previewCaps() }, { confirm: () => confirm.promise })
await settle(); await g.panel.openTask(1)
const retry = g.panel.retryTask(); await settle(); g.props.capabilities.preview_ready = false; confirm.resolve(true); await retry
assert.equal(g.calls.filter(call => call[0] === 'retry').length, 0)
g.dispose()
})
test('preview task origin permanently blocks apply even after global mode becomes normal or can_apply is forged true', async () => {
for (const capabilities of [previewCaps({ can_apply: true, audio_verified: true }), { ...caps(), preview_only: false, can_review: true }]) {
const f = setupPanel({ followupAudioDetail: async () => task(1, { preview_only: true }) }, { capabilities })
await settle(); await f.panel.openTask(1)
assert.equal(f.panel.previewOnly.value, true)
assert.equal(f.panel.canEditReview.value, true)
assert.equal(f.panel.canApplyReview.value, false)
await f.panel.applySelected()
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
assert.deepEqual(f.events, [])
f.dispose()
}
assert.equal(state.followupCanApply(caps(), true), false)
})
test('preview never emits an applied event from polling or a late normal-mode apply response', async () => {
let current = task(1, { preview_only: true })
const f = setupPanel({ followupAudioLists: async () => ({ items: [current] }), followupAudioDetail: async () => current }, { capabilities: previewCaps() })
await settle(); await f.panel.openTask(1)
current = { ...current, version: 2, status: 'applied' }; f.tick(); await settle()
assert.equal(f.panel.detail.value.status, 'applied')
assert.deepEqual(f.events, [])
f.dispose()
const response = deferred()
const g = setupPanel({ followupAudioApply: async payload => { g.calls.push(['apply', payload]); return response.promise } })
await settle(); await g.panel.openTask(1)
const applying = g.panel.applySelected(); await settle()
assert.equal(g.calls.filter(call => call[0] === 'apply').length, 1, 'request began in normal mode, not preview')
g.props.capabilities = previewCaps(); await settle(); response.resolve({ id: 1, status: 'applied', applied_items: [] }); await applying
assert.deepEqual(g.events, [])
assert.equal(g.notices.filter(([kind]) => kind === 'success').length, 0)
g.dispose()
})
test('outside preview allowlist panel remains hidden and makes no feature requests; revocation fences old responses', async () => {
for (const enabled of [false, true]) {
const f = setupPanel({}, { capabilities: previewCaps({ enabled, test_scope_allowed: false, models: [] }) })
await settle(); await f.panel.openTask(1); f.panel.openUpload(); await f.panel.saveDraft(); await f.panel.applySelected()
assert.equal(f.panel.featureEnabled.value, false)
assert.equal(f.panel.uploadVisible.value, false)
assert.deepEqual(f.calls, [])
assert.deepEqual(f.events, [])
f.dispose()
}
const pending = deferred()
const g = setupPanel({ followupAudioDetail: async () => pending.promise }, { capabilities: previewCaps() })
await settle(); const opening = g.panel.openTask(1); await settle()
g.props.capabilities = previewCaps({ enabled: false, test_scope_allowed: false, models: [] }); await settle()
pending.resolve(task(1, { preview_only: true, transcript: '旧范围合成文本' })); await opening
assert.equal(g.panel.detail.value, null)
assert.equal(g.timers.size, 0)
assert.equal(g.panel.featureEnabled.value, false)
g.dispose()
assert.match(fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8'), /<section v-if="featureEnabled"/)
})
test('preview readonly and can_review denial cannot save roles or drafts, while old patient saves stay fenced', async () => {
for (const props of [{ viewOnly: true, capabilities: previewCaps() }, { capabilities: previewCaps({ can_review: false, can_apply: true }) }]) {
const f = setupPanel({ followupAudioDetail: async () => stereoTask({ preview_only: true }) }, props)
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft(); await f.panel.applySelected()
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.equal(f.calls.filter(call => ['save', 'apply'].includes(call[0])).length, 0)
f.dispose()
}
const pending = deferred()
const g = setupPanel({ followupAudioDetail: async () => stereoTask({ preview_only: true }), followupAudioSaveDraft: async () => pending.promise }, { capabilities: previewCaps() })
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('left_service')
const saving = g.panel.saveDraft(); await settle(); g.props.diagnosisId = 20; await settle()
pending.resolve(stereoTask({ preview_only: true, version: 2, channel_roles: 'left_service' })); await saving
assert.equal(g.panel.detail.value, null)
assert.equal(g.panel.channelRoles.value, 'unconfirmed')
assert.deepEqual(g.events, [])
assert.deepEqual(g.notices, [])
g.dispose()
})
test('actual patient editor outside preview allowlist still opens and edits its ordinary diagnosis form', async () => {
const edits = [], warnings = [], events = []
const filename = path.join(componentDir, '../edit.vue')
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
const script = compileScript(descriptor, { id: 'preview-editor-runtime' })
const module = execute(script.content, name => {
if (name === 'vue') return { ...vue, onBeforeUnmount() {} }
if (name === '@/api/tcm') return {
tcmDiagnosisEdit: async payload => { edits.push(payload); return {} },
tcmDiagnosisDetail: async ({ id }) => ({ id: String(id), patient_id: String(id + 100), patient_name: '合成患者', phone: '13800000000', id_card: '', remark: '普通原始备注', diabetes_discovery_year: '', show_card: 1 })
}
if (name === '@/api/app') return { getDictData: async () => ({}) }
if (name === '@/api/followupAudio') return { followupAudioCapabilities: async () => previewCaps({ enabled: false, test_scope_allowed: false, can_upload: false, can_review: false, models: [] }) }
if (name === '@/utils/feedback') return { default: { msgWarning: msg => warnings.push(msg) } }
if (name === '@/utils/perm') return { hasPermission: () => true }
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: { id: 1 } }) }
if (name === '@/stores/modules/app') return { default: () => ({ getImageUrl: value => value }) }
if (name === 'element-plus') return { ElMessage: { warning() {} } }
if (name === '@element-plus/icons-vue' || name === '@/api/patient') return {}
if (name.endsWith('.vue')) return { default: {} }
throw new Error(`Unexpected editor import: ${name}`)
})
const scope = vue.effectScope()
try {
const editor = scope.run(() => module.default.setup({}, { expose() {}, emit: (...args) => events.push(args) }))
await editor.open('edit', 30); await settle()
assert.equal(editor.visible.value, true)
assert.equal(editor.formData.value.id, '30')
assert.equal(editor.followupCapabilities.value.enabled, false)
assert.equal(editor.followupCapabilities.value.test_scope_allowed, false)
assert.equal(editor.basicDirty.value, false)
editor.formData.value.remark = '合成普通表单编辑'
assert.equal(editor.basicDirty.value, true)
await editor.handleSubmit()
assert.equal(edits.length, 1, 'ordinary diagnosis mock API remains usable; no real patient write')
assert.equal(edits[0].remark, '合成普通表单编辑')
assert.match(descriptor.template.content, /v-if="followupCapabilities\?\.enabled && formData.id"/)
} finally { scope.stop() }
})
test('preview upload stops before create when readiness, upload permission or allowed scope is withdrawn', async () => {
for (const withdrawn of [{ preview_ready: false }, { can_upload: false }, { test_scope_allowed: false, enabled: false }]) {
const pending = deferred()
const f = setupPanel({ followupAudioUploadChunk: async () => pending.promise }, { capabilities: previewCaps() })
await settle(); f.panel.file.value = new File(['synthetic'], 'test.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
const uploading = f.panel.submitUpload(); await settle()
Object.assign(f.props.capabilities, withdrawn); await settle(); pending.resolve({ received: true }); await uploading
assert.equal(f.calls.filter(call => call[0] === 'complete' || call[0] === 'create').length, 0)
assert.equal(f.panel.uploading.value, false)
assert.deepEqual(f.events, [])
f.dispose()
}
})
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.env
*.private.*
asr-key
+34
View File
@@ -0,0 +1,34 @@
# Isolated Dify ASR deployment — 2026-10-09
This directory adds only a CPU-only fixed-upstream bridge, a new speech2text model/default, and an independent Dify chat App. Do not apply its compose file to the old Dify or ASR project. The two networks are external and must not be deleted.
- AI host deployment root: `/home/www/qwen-vllm/dify-integration-20261009`
- Public API base: `https://ai.zhenyangtang.com.cn/v1`
- New App: `ea0d7293-3d89-425d-a236-015fac7e2470`
- App mode chat; public site disabled; API enabled; max active chat requests 1.
- ASR: `Qwen/Qwen3-ASR-1.7B`; extraction: existing `qwen3.6-35b`.
- App max_tokens 8192, temperature 0, enable_thinking false; JSON requested in the system/query prompts, not native JSON-schema enforcement. Existing Qwen model has strict compatibility/not_supported thinking; SDK may filter the explicit false parameter. The existing Qwen server's `--default-chat-template-kwargs {"enable_thinking":false}` is the effective server control, verified without changing it.
- Binding revision: `followup-dify-20261009-v1-99ebe159aef4dce6`. Any App/provider/default/model-serving/bridge change requires a new revision and new acceptance. This is a version binding, not an automatic remote drift monitor.
- Bridge: existing pinned Python 3.12 image, two Docker networks, no host port, no GPU, UID1000, read-only root, all capabilities dropped, no-new-privileges, 256MiB/0.5CPU/64PIDs; in-flight 1, token bucket 120/min burst4, 30MiB+64KiB multipart limit, 180s fixed-upstream timeout, no redirects or POST retry.
- Secrets exist only in private 0600 host files and encrypted Dify credentials. Never print `resources.private.json`, `asr-key`, `dify-runtime.env`, or `operator-error.private.log`.
- Provisioning uses deployed `ModelProviderService`, `AppService`, model-config and key controllers, including provider validation/encryption. It never handcrafts encrypted provider rows. Provision is single-use; inspect exact state after any failure before resuming.
## Validation
Run `python3 test_bridge.py` locally for eight synthetic mock-server tests. `public_probe.py PRIVATE_ENV SYNTHETIC_WAV [all|asr|chat] [EXPECTED_CANARIES_JSON]` always verifies HTTPS certificates/hostname, never emits keys, and sends canary strings only in audio, not in ASR form prompts. Optional expected canaries are verifier inputs only. Preserve failures; passing connection tests do not establish full transcription accuracy.
Initial ASR fixture reproduced a homophone error: `红色石榴` became `红色石流`; its strict four-canary result remains failed even though the other three unique canaries match and HTTPS ASR returns 200. This release is for synthetic/preview-only validation, not clinical writeback or an assertion of complete accuracy.
Webhost Python/OpenSSL validates this TLS endpoint. Webhost legacy PHP/cURL NSS certificate-key-usage behavior requires independent acceptance; do not disable TLS verification or infer PHP success from Python success.
## Exact rollback
On AI host, execute only this release's `/home/www/qwen-vllm/dify-integration-20261009/ROLLBACK.sh`:
- `--quiesce`: disable only the new App API and stop only its recorded bridge container; verified live.
- `--restore`: start the same new bridge, check health, re-enable only the new App API; verified live.
- `--remove-new-objects`: revoke the precise new key, delete the precise new App, remove this newly introduced non-secret speech2text default and credential through guarded ORM/service operations, and remove only this bridge. This final removal is packaged, not exercised against the live desired deployment. It aborts if another App now uses speech2text or resource IDs differ.
No command deletes shared networks, old providers/models/apps, model files, or old containers. Apply rollback before removing the private resource manifest; preserve it for identity checks. No old source files or global configuration are edited.
After live quiesce/restore, a separate 13.03075s synthetic fixture (`蓝色风筝/绿色森林/白色帆船/黑色雨伞`) passed all four audio-only canaries through the Webhost's fully verified HTTPS Python/OpenSSL route (ASR 200, 1.284s); the JSON chat canary also passed (200, 0.539s). This additional successful connection case does not overwrite the retained first fixture's homophone failure.
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# Only this release. Default reversible quiesce; --remove-new-objects is permanent.
set -eu
BASE=/home/www/qwen-vllm/dify-integration-20261009
ID=7c3ca77d77cac063d6b98310e59d2a73b896a0f5171ba002d41eccab1c067a59
MODE=${1:---quiesce}
case "$MODE" in --quiesce|--restore|--remove-new-objects) ;; *) echo 'usage: ROLLBACK.sh [--quiesce|--restore|--remove-new-objects]' >&2; exit 2;; esac
python3 - "$BASE" <<'PY'
import json,pathlib,sys
r=json.loads((pathlib.Path(sys.argv[1])/'resources.private.json').read_text())
expected={'app_id':'ea0d7293-3d89-425d-a236-015fac7e2470','key_id':'ecfb7628-1369-41fa-b132-d24074af8470','asr_model_id':'e55a7374-a43e-4b86-9323-005a2a72458b','asr_credential_id':'01a11f7e-a148-7b9c-82c7-ffc1425b40ee','asr_default_id':'56ca4093-61fa-446b-ad77-b10deb9daaa4'}
assert all(r.get(k)==v for k,v in expected.items()),'RESOURCE_IDENTITY_MISMATCH'
print('EXACT_NEW_RESOURCES_VERIFIED')
PY
ACTUAL=$(docker inspect --format '{{.Id}}' followup-asr-bridge-20261009)
[ "$ACTUAL" = "$ID" ] || { echo 'BRIDGE_IDENTITY_MISMATCH' >&2; exit 3; }
[ "$(docker inspect --format '{{index .Config.Labels "com.zyt.release"}}' "$ID")" = followup-dify-20261009 ] || exit 4
if [ "$MODE" = --restore ]; then
docker start "$ID"
for n in 1 2 3 4 5 6 7 8 9 10; do
if docker exec "$ID" python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health',timeout=4).read()"; then break; fi
sleep 1
done
docker exec "$ID" python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health',timeout=4).read()"
python3 "$BASE/host_operator.py" enable
echo RESTORED_NEW_ASR_APP_AND_BRIDGE
elif [ "$MODE" = --quiesce ]; then
python3 "$BASE/host_operator.py" disable
docker stop --time 10 "$ID"
echo NEW_ASR_APP_AND_BRIDGE_QUIESCED
else
python3 "$BASE/host_operator.py" remove
docker stop --time 10 "$ID"
docker rm "$ID"
echo NEW_APP_MODEL_DEFAULT_BRIDGE_REMOVED_SHARED_RESOURCES_PRESERVED
fi
+165
View File
@@ -0,0 +1,165 @@
"""Fixed-upstream ASR gateway: stdlib only, no retry, no body/header logging."""
from __future__ import annotations
import hmac
import http.client
import json
import os
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
MAX_BODY = 30 * 1024 * 1024 + 65536 # Dify accepts 30 MiB file; multipart allowance.
MAX_RESPONSE = 4 * 1024 * 1024
UPSTREAM_HOST = 'followup-audio-asr'
UPSTREAM_PORT = 8000
RATE_PER_MINUTE = 120
RATE_BURST = 4
class Gate:
def __init__(self, rate=RATE_PER_MINUTE, burst=RATE_BURST):
self.rate, self.burst = rate, burst
self.tokens, self.at = float(burst), time.monotonic()
self.lock = threading.Lock()
self.active = threading.BoundedSemaphore(1)
def take(self):
with self.lock:
now = time.monotonic()
self.tokens = min(float(self.burst), self.tokens + (now - self.at) * self.rate / 60)
self.at = now
if self.tokens < 1:
return False
self.tokens -= 1
return True
class Server(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
request_queue_size = 8
def __init__(self, address, handler, *, key, upstream=(UPSTREAM_HOST, UPSTREAM_PORT), rate=RATE_PER_MINUTE, burst=RATE_BURST, timeout=180):
super().__init__(address, handler)
self.key, self.upstream, self.timeout = key, upstream, timeout
self.gate = Gate(rate, burst)
self.connections = threading.BoundedSemaphore(8)
def process_request(self, request, client_address):
if not self.connections.acquire(blocking=False):
try:
request.sendall(b'HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
finally:
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except BaseException:
self.connections.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.connections.release()
def handle_error(self, request, client_address):
# Deliberately suppress tracebacks/request fragments from client errors.
print(json.dumps({'event': 'client_error'}), flush=True)
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
server_version = 'FollowupASRBridge/1'
sys_version = ''
def setup(self):
super().setup()
self.connection.settimeout(30)
def log_message(self, format, *args):
pass
def reply(self, code, body, content_type='application/json'):
self.close_connection = True
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.send_header('Connection', 'close')
self.send_header('Cache-Control', 'no-store')
if code == 429:
self.send_header('Retry-After', '1')
self.end_headers()
self.wfile.write(body)
def error_json(self, code, label):
self.reply(code, json.dumps({'error': label}).encode())
def authorized(self):
supplied = self.headers.get('Authorization', '')
return hmac.compare_digest(supplied.encode(), b'Bearer ' + self.server.key)
def upstream_call(self, method, path, body=None, content_type=None, health=False):
conn = http.client.HTTPConnection(*self.server.upstream, timeout=3 if health else self.server.timeout)
try:
headers = {} if health else {'Authorization': 'Bearer ' + self.server.key.decode()}
if content_type:
headers['Content-Type'] = content_type
conn.request(method, path, body=body, headers=headers)
response = conn.getresponse()
result = response.read(MAX_RESPONSE + 1)
if len(result) > MAX_RESPONSE:
return self.error_json(502, 'upstream_response_too_large')
if health:
return self.reply(200 if response.status == 200 else 503, b'{"status":"ok"}' if response.status == 200 else b'{"status":"unhealthy"}')
self.reply(response.status, result, response.getheader('Content-Type', 'application/json'))
except (OSError, http.client.HTTPException, TimeoutError):
self.error_json(503 if health else 502, 'upstream_unavailable')
finally:
conn.close()
def do_GET(self):
if self.path == '/health':
return self.upstream_call('GET', '/health', health=True)
if self.path != '/v1/models':
return self.error_json(404, 'not_found')
if not self.authorized():
return self.error_json(401, 'unauthorized')
return self.upstream_call('GET', '/v1/models')
def do_POST(self):
if self.path != '/v1/audio/transcriptions':
return self.error_json(404, 'not_found')
if not self.authorized():
return self.error_json(401, 'unauthorized')
if self.headers.get('Transfer-Encoding') or not self.headers.get('Content-Length', '').isdigit():
return self.error_json(411, 'content_length_required')
length = int(self.headers['Content-Length'])
if length > MAX_BODY:
return self.error_json(413, 'body_too_large')
if length <= 0:
return self.error_json(400, 'empty_body')
content_type = self.headers.get('Content-Type', '')
if not content_type.startswith('multipart/form-data;') or 'boundary=' not in content_type:
return self.error_json(415, 'multipart_required')
if not self.server.gate.active.acquire(blocking=False):
return self.error_json(429, 'asr_busy')
try:
if not self.server.gate.take():
return self.error_json(429, 'rate_limit')
try:
body = self.rfile.read(length)
except (OSError, socket.timeout):
return self.error_json(408, 'upload_timeout')
if len(body) != length:
return self.error_json(400, 'incomplete_body')
return self.upstream_call('POST', '/v1/audio/transcriptions', body, content_type)
finally:
self.server.gate.active.release()
if __name__ == '__main__':
key = Path('/run/secrets/asr-key').read_bytes().strip()
if len(key) < 16 or b'\n' in key or b'\r' in key:
raise SystemExit('invalid_secret_file')
print(json.dumps({'event': 'started', 'rate_per_minute': RATE_PER_MINUTE, 'inflight': 1, 'host_ports': False}), flush=True)
Server(('0.0.0.0', 8080), Handler, key=key).serve_forever()
@@ -0,0 +1,41 @@
services:
bridge:
image: sha256:34386ef0cb081344d7ec1c103ba398e6e9f64e9ab3a1509accc92a4e24a07258
container_name: followup-asr-bridge-20261009
user: '1000:1000'
entrypoint: ['python', '-B', '/app/bridge.py']
working_dir: /app
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: ['no-new-privileges:true']
pids_limit: 64
cpus: 0.50
mem_limit: 256m
memswap_limit: 256m
tmpfs: ['/tmp:size=16m,noexec,nosuid,nodev']
volumes:
- './bridge.py:/app/bridge.py:ro'
- './asr-key:/run/secrets/asr-key:ro'
networks:
dify:
aliases: [followup-asr-bridge]
asr: {}
healthcheck:
test: ['CMD', 'python', '-c', "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health',timeout=4).read()"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: json-file
options: {max-size: 1m, max-file: '2'}
labels:
com.zyt.release: followup-dify-20261009
networks:
dify:
external: true
name: dify_v1110_eera_default
asr:
external: true
name: followup-audio-asr_default
@@ -0,0 +1,119 @@
"""Execute in Dify API Python with PAYLOAD supplied by private host wrapper.
Uses deployed services/controllers for credential encryption and app config.
Never print this process output to public logs: create returns a new app key to
its private caller; the wrapper writes it 0600 and emits only public metadata.
"""
import hashlib
import inspect
import json
import logging
import sys
logging.disable(logging.CRITICAL)
from app_factory import create_app
app = create_app()
from sqlalchemy import select
from extensions.ext_database import db
from models.account import Account, Tenant, TenantAccountJoin
from models.model import App, ApiToken
from models.provider import ProviderModel, ProviderModelCredential, TenantDefaultModel
from services.app_service import AppService
from services.model_provider_service import ModelProviderService
PROVIDER = 'langgenius/openai_api_compatible/openai_api_compatible'
ASR = 'Qwen/Qwen3-ASR-1.7B'
APP_NAME = 'followup-audio-isolated-20261009'
ENDPOINT = 'http://followup-asr-bridge:8080/v1'
CONFIG = {
'model': {'provider': PROVIDER, 'name': 'qwen3.6-35b', 'mode': 'chat',
'completion_params': {'max_tokens': 8192, 'temperature': 0.0, 'top_p': 1.0, 'enable_thinking': False}},
'pre_prompt': '仅依据本次用户请求处理提供的文本。遵循用户给定的 JSON 格式,只返回最终 JSON,不输出思考过程、解释或 Markdown。不得把示例当成事实。',
'prompt_type': 'simple', 'user_input_form': [],
'speech_to_text': {'enabled': True}, 'text_to_speech': {'enabled': False},
'suggested_questions_after_answer': {'enabled': False},
'retriever_resource': {'enabled': False}, 'sensitive_word_avoidance': {'enabled': False},
'file_upload': {'enabled': False},
}
with app.app_context():
# Anchor only to the existing default Qwen workspace. Fail if ambiguous.
tenant_ids = db.session.scalars(select(TenantDefaultModel.tenant_id).where(
TenantDefaultModel.model_type == 'text-generation', TenantDefaultModel.model_name == 'qwen3.6-35b')).all()
assert len(tenant_ids) == 1, 'AMBIGUOUS_WORKSPACE'
tenant_id = tenant_ids[0]
tenant = db.session.get(Tenant, tenant_id)
owner = db.session.scalars(select(Account).join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id).where(
TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == 'owner')).one()
owner.current_tenant = tenant
def current_app_owned():
resource = PAYLOAD['resources']
obj = db.session.get(App, resource['app_id'])
assert obj and obj.name == APP_NAME and obj.tenant_id == tenant_id, 'APP_IDENTITY_MISMATCH'
return obj
def public_state(obj):
model = db.session.scalars(select(ProviderModel).where(ProviderModel.tenant_id == tenant_id,
ProviderModel.provider_name == PROVIDER, ProviderModel.model_name == ASR, ProviderModel.model_type == 'speech2text')).one()
default = db.session.scalars(select(TenantDefaultModel).where(TenantDefaultModel.tenant_id == tenant_id,
TenantDefaultModel.model_type == 'speech2text')).one()
config = obj.app_model_config.to_dict()
return {'app_id':obj.id,'app_name':obj.name,'tenant_id':tenant_id,'app_mode':obj.mode,
'enable_api':obj.enable_api,'enable_site':obj.enable_site,'config_id':obj.app_model_config_id,
'speech_to_text':obj.app_model_config.speech_to_text_dict,
'model':obj.app_model_config.model_dict,'pre_prompt_sha256':hashlib.sha256(obj.app_model_config.pre_prompt.encode()).hexdigest(),
'config_sha256':hashlib.sha256(json.dumps(config,ensure_ascii=False,sort_keys=True,default=str).encode()).hexdigest(),
'asr_model_id':model.id,'asr_credential_id':model.credential_id,'asr_default_id':default.id,
'asr_model':model.model_name,'asr_default':default.model_name,'provider':PROVIDER,'endpoint':ENDPOINT,
'base_url':'https://ai.zhenyangtang.com.cn/v1','max_active_requests':obj.max_active_requests}
result = {}
with app.test_request_context('/', method='POST', json=CONFIG):
app.login_manager._update_request_context_with_user(owner)
svc = AppService()
action = PAYLOAD['action']
if action == 'create':
assert not db.session.scalar(select(App.id).where(App.name == APP_NAME)), 'APP_NAME_ALREADY_EXISTS'
assert not db.session.scalar(select(ProviderModel.id).where(ProviderModel.tenant_id == tenant_id,ProviderModel.model_type == 'speech2text')), 'SPEECH2TEXT_NOT_EMPTY'
assert not db.session.scalar(select(TenantDefaultModel.id).where(TenantDefaultModel.tenant_id == tenant_id,TenantDefaultModel.model_type == 'speech2text')), 'DEFAULT_NOT_EMPTY'
ModelProviderService().create_model_credential(tenant_id,PROVIDER,'speech2text',ASR,
{'api_key':PAYLOAD['asr_key'],'endpoint_url':ENDPOINT,'endpoint_model_name':ASR,'language':'zh','initial_prompt':'','display_name':'Followup ASR isolated'},
'followup-asr-isolated-20261009')
ModelProviderService().update_default_model_of_model_type(tenant_id,'speech2text',PROVIDER,ASR)
obj=svc.create_app(tenant_id,{'mode':'chat','name':APP_NAME,'description':'Isolated synthetic-verified ASR and JSON extraction. Preview only; no clinical writes.','icon_type':'emoji','icon':'🎙️','icon_background':'#E4FBCC'},owner)
svc.update_app_site_status(obj,False)
from controllers.console.app.model_config import ModelConfigResource
inspect.unwrap(ModelConfigResource.post)(ModelConfigResource(),obj)
svc.update_app(obj, {'name':APP_NAME,'description':obj.description,'icon_type':obj.icon_type,'icon':obj.icon,'icon_background':obj.icon_background,'use_icon_as_answer_icon':False,'max_active_requests':1})
svc.update_app_api_status(obj,True)
from controllers.console.apikey import AppApiKeyListResource, BaseApiKeyListResource
token,status=inspect.unwrap(BaseApiKeyListResource.post)(AppApiKeyListResource(),obj.id)
assert status == 201
result={'public':public_state(obj),'private':{'api_key':token.token,'key_id':token.id}}
elif action in ('status','disable','enable'):
obj=current_app_owned()
if action != 'status':svc.update_app_api_status(obj,action == 'enable')
result={'public':public_state(obj)}
elif action == 'remove':
resource=PAYLOAD['resources'];obj=current_app_owned()
credential=db.session.get(ProviderModelCredential,resource['asr_credential_id'])
recorded_default=db.session.get(TenantDefaultModel,resource['asr_default_id'])
assert credential and credential.tenant_id==tenant_id and credential.model_name==ASR and credential.model_type=='speech2text', 'CREDENTIAL_IDENTITY_MISMATCH'
assert recorded_default and recorded_default.tenant_id==tenant_id and recorded_default.model_name==ASR and recorded_default.model_type=='speech2text', 'DEFAULT_IDENTITY_MISMATCH'
for other in db.session.scalars(select(App).where(App.tenant_id==tenant_id,App.id!=obj.id)).all():
features=(other.workflow.features_dict if other.workflow else {}) if other.mode in ('advanced-chat','workflow') else ({'speech_to_text':other.app_model_config.speech_to_text_dict} if other.app_model_config else {})
assert not features.get('speech_to_text',{}).get('enabled'), 'ANOTHER_APP_NOW_USES_ASR'
svc.update_app_api_status(obj,False)
# Only the precise new app key, via the deployed API controller.
from controllers.console.apikey import AppApiKeyResource, BaseApiKeyResource
key_id=resource['key_id']
if db.session.get(ApiToken,key_id):
inspect.unwrap(BaseApiKeyResource.delete)(AppApiKeyResource(),obj.id,key_id)
svc.delete_app(obj)
default=db.session.get(TenantDefaultModel,resource['asr_default_id'])
assert default and default.tenant_id==tenant_id and default.model_type=='speech2text' and default.model_name==ASR, 'DEFAULT_IDENTITY_MISMATCH'
# No service offers reset-to-empty; remove only this recorded, new, non-secret default row.
db.session.delete(default);db.session.commit()
ModelProviderService().remove_model_credential(tenant_id,PROVIDER,'speech2text',ASR,resource['asr_credential_id'])
assert not db.session.scalar(select(App.id).where(App.id==resource['app_id']))
assert not db.session.scalar(select(ProviderModel.id).where(ProviderModel.id==resource['asr_model_id']))
result={'public':{'removed_app_id':resource['app_id'],'speech2text_restored_empty':True}}
else:raise ValueError('UNKNOWN_ACTION')
print('OPERATOR_RESULT='+json.dumps(result,ensure_ascii=False,default=str))
@@ -0,0 +1,27 @@
"""Host-private Dify service invocation, emits no credentials."""
import json,os,pathlib,subprocess,sys
BASE=pathlib.Path(__file__).resolve().parent
os.umask(0o077)
action=sys.argv[1]
payload={'action':action}
resources=BASE/'resources.private.json'
if action=='create':
payload['asr_key']=(BASE/'asr-key').read_text().strip()
assert not resources.exists(), 'RESOURCE_FILE_ALREADY_EXISTS'
else:payload['resources']=json.loads(resources.read_text())
code='PAYLOAD='+repr(payload)+'\n'+(BASE/'dify_operator.py').read_text()
p=subprocess.run(['docker','exec','-i','dify_v1110_eera-api-1','python','-'],input=code,text=True,capture_output=True)
# Never print raw stdout/stderr: credentials may exist in structured output/errors.
lines=[s for s in p.stdout.splitlines() if s.startswith('OPERATOR_RESULT=')]
if p.returncode or len(lines)!=1:
# Private diagnostic file; callers must not copy it into public artifacts.
(BASE/'operator-error.private.log').write_text(p.stdout+'\n'+p.stderr)
print(json.dumps({'action':action,'exit_status':p.returncode,'result':'FAILED','diagnostic_private':True}))
sys.exit(p.returncode or 1)
data=json.loads(lines[0].split('=',1)[1])
if action=='create':
value={**data['public'],**data['private']}
resources.write_text(json.dumps(value,ensure_ascii=False,indent=2)+'\n');resources.chmod(0o600)
env='DIFY_ASR_APP_KEY='+value['api_key']+'\nDIFY_ASR_APP_ID='+value['app_id']+'\nDIFY_ASR_BASE_URL='+value['base_url']+'\nDIFY_ASR_EXPECTED_MODEL=Qwen/Qwen3-ASR-1.7B\nDIFY_EXTRACTION_EXPECTED_MODEL=qwen3.6-35b\nDIFY_BINDING_REVISION=followup-dify-20261009-v1-'+value['config_sha256'][:16]+'\n'
(BASE/'dify-runtime.env').write_text(env);(BASE/'dify-runtime.env').chmod(0o600)
print(json.dumps({'action':action,'exit_status':p.returncode,'result':'PASS',**data['public']},ensure_ascii=False))
@@ -0,0 +1,42 @@
"""Synthetic-only HTTPS acceptance; Python 3.6+, never disables TLS checks."""
import hashlib,json,pathlib,re,ssl,sys,time,urllib.error,urllib.request,uuid
ENV=pathlib.Path(sys.argv[1])
WAV=pathlib.Path(sys.argv[2])
MODE=sys.argv[3] if len(sys.argv)>3 else 'all'
CANARIES=json.loads(pathlib.Path(sys.argv[4]).read_text(encoding='utf-8')) if len(sys.argv)>4 else ['紫色海豚','金色河流','银色树叶','红色石榴']
env=dict(line.split('=',1) for line in ENV.read_text(encoding='utf-8').splitlines() if '=' in line)
base=env['DIFY_ASR_BASE_URL'];key=env['DIFY_ASR_APP_KEY']
ctx=ssl.create_default_context()
def request(path,data,content_type,auth=True):
h={'Content-Type':content_type}
if auth:h['Authorization']='Bearer '+key
req=urllib.request.Request(base+path,data=data,headers=h,method='POST')
start=time.monotonic()
try:
with urllib.request.urlopen(req,context=ctx,timeout=200) as response:
code=response.status;body=response.read().decode()
except urllib.error.HTTPError as error:
code=error.code;body=error.read().decode()
return {'status':code,'elapsed_seconds':round(time.monotonic()-start,3),'body':json.loads(body)}
def audio():
boundary='synthetic'+uuid.uuid4().hex
body=('--'+boundary+'\r\nContent-Disposition: form-data; name="user"\r\n\r\nsynthetic-asr-acceptance-20261009\r\n--'+boundary+'\r\nContent-Disposition: form-data; name="file"; filename="synthetic.wav"\r\nContent-Type: audio/wav\r\n\r\n').encode()+WAV.read_bytes()+('\r\n--'+boundary+'--\r\n').encode()
return request('/audio-to-text',body,'multipart/form-data; boundary='+boundary)
r={'synthetic':True,'tls_verify':True,'check_hostname':ctx.check_hostname,'ssl':ssl.OPENSSL_VERSION,'mode':MODE,'app_id':env['DIFY_ASR_APP_ID'],'base_url':base,'binding_revision':env['DIFY_BINDING_REVISION']}
if MODE in ('all','asr'):
r['unauthorized']=request('/audio-to-text',b'','application/octet-stream',False)
r['asr']=audio()
text=r['asr']['body'].get('text','')
r['audio_only_canaries']={x:x in text for x in CANARIES}
r['asr_pass']=r['unauthorized']['status']==401 and r['asr']['status']==200 and all(r['audio_only_canaries'].values())
if MODE in ('all','chat'):
query='这是独立的合成 JSON 验收,不包含患者数据。请只输出 JSON 对象:{"canary":"BLUE_KITE_20261009","sum":42,"preview_only":true}。不要添加其他字段、解释或 Markdown。'
payload={'inputs':{},'query':query,'response_mode':'blocking','user':'synthetic-chat-acceptance-20261009','auto_generate_name':False}
r['chat']=request('/chat-messages',json.dumps(payload,ensure_ascii=False).encode(),'application/json')
answer=r['chat']['body'].get('answer','')
try:parsed=json.loads(answer)
except ValueError:parsed=None
r['chat_pass']=r['chat']['status']==200 and parsed=={'canary':'BLUE_KITE_20261009','sum':42,'preview_only':True} and bool(r['chat']['body'].get('message_id')) and '<think>' not in answer
r['passed']=all(r.get(k,True) for k in ['asr_pass','chat_pass'])
print(json.dumps(r,ensure_ascii=True,indent=2))
sys.exit(0 if r['passed'] else 1)
@@ -0,0 +1,56 @@
"""Local synthetic mock-server tests, no model calls or secrets."""
import http.client
import json
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import bridge
KEY = b'synthetic-only-unit-test-secret'
class Upstream(BaseHTTPRequestHandler):
calls = 0
slow = False
entered = threading.Event()
def log_message(self, *args): pass
def do_GET(self):
body = b'{}'; self.send_response(200); self.send_header('Content-Length','2'); self.end_headers(); self.wfile.write(body)
def do_POST(self):
type(self).calls += 1
type(self).entered.set()
self.rfile.read(int(self.headers['Content-Length']))
if type(self).slow: time.sleep(0.25)
assert self.headers['Authorization'] == 'Bearer ' + KEY.decode()
body = b'{"text":"synthetic-only-canary"}'
self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
class BridgeTests(unittest.TestCase):
def setUp(self):
Upstream.calls=0;Upstream.slow=False;Upstream.entered.clear()
self.u=ThreadingHTTPServer(('127.0.0.1',0),Upstream)
self.b=bridge.Server(('127.0.0.1',0),bridge.Handler,key=KEY,upstream=self.u.server_address)
for s in [self.u,self.b]: threading.Thread(target=s.serve_forever,daemon=True).start()
def tearDown(self):
for s in [self.b,self.u]:s.shutdown();s.server_close()
def call(self,body=b'x',headers=None,path='/v1/audio/transcriptions',method='POST'):
h={'Authorization':'Bearer '+KEY.decode(),'Content-Type':'multipart/form-data; boundary=test'}
if headers: h.update(headers)
c=http.client.HTTPConnection(*self.b.server_address,timeout=2);c.request(method,path,body,h);r=c.getresponse();result=(r.status,r.read());c.close();return result
def test_valid_fixed_upstream(self):
self.assertEqual(self.call(),(200,b'{"text":"synthetic-only-canary"}'));self.assertEqual(Upstream.calls,1)
def test_auth_denied_without_upstream(self):
self.assertEqual(self.call(headers={'Authorization':'Bearer wrong'})[0],401);self.assertEqual(Upstream.calls,0)
def test_body_bound_before_read(self):
self.assertEqual(self.call(headers={'Content-Length':str(bridge.MAX_BODY+1)})[0],413);self.assertEqual(Upstream.calls,0)
def test_fixed_path_only(self):
self.assertEqual(self.call(path='/v1/chat/completions')[0],404);self.assertEqual(Upstream.calls,0)
def test_concurrency_exactly_one(self):
Upstream.slow=True;out=[];t=threading.Thread(target=lambda:out.append(self.call()[0]));t.start();self.assertTrue(Upstream.entered.wait(1));self.assertEqual(self.call()[0],429);t.join();self.assertEqual(out,[200]);self.assertEqual(Upstream.calls,1)
def test_rate_429_no_retry(self):
self.b.gate=bridge.Gate(rate=0,burst=1);self.assertEqual(self.call()[0],200);self.assertEqual(self.call()[0],429);self.assertEqual(Upstream.calls,1)
def test_upstream_down_no_retry(self):
self.b.upstream=('127.0.0.1',1);self.assertEqual(self.call()[0],502);self.assertEqual(Upstream.calls,0)
def test_wrong_content_type(self):
self.assertEqual(self.call(headers={'Content-Type':'application/json'})[0],415);self.assertEqual(Upstream.calls,0)
if __name__=='__main__':unittest.main(verbosity=2)
@@ -0,0 +1,40 @@
# Follow-up preview runtime (2026-10-09)
This is an opt-in, diagnosis-ID-scoped preview. It never permits clinical adoption; full audio/semantic verification remains false. Do not use a preview fingerprint as full verification. Preview-origin tasks remain non-adoptable after configuration changes.
## Dedicated media tools
The CentOS 7 Webhost needs a private FFmpeg/FFprobe path, not a system upgrade. Installed location is `/opt/followup-audio-tools/ffmpeg-9.0.2/` (no PATH or OS package changes). Source was downloaded from `https://ffmpeg.org/releases/ffmpeg-9.0.2.tar.xz`, SHA256 `8c3850283eb25fa026482078a04051e0be17347b09ef81a0849bec15a96e002e`, with detached PGP signature verified against the official release key `FCF986EA15E6E293A5644F10B4322F04D67658D8`.
It was built on the AI host using GCC 12, `nice -n 19 make -j1`, static libc, generic x86-64 / Linux 3.2 baseline, without network protocols or optional external codec libraries. Build flags:
```sh
./configure --prefix=/opt/followup-audio-tools/ffmpeg-9.0.2 \
--disable-autodetect --disable-shared --enable-static --extra-cflags=-O2 --extra-ldflags=-static \
--disable-x86asm --disable-doc --disable-debug --disable-network --disable-everything \
--enable-ffmpeg --enable-ffprobe --enable-avcodec --enable-avformat --enable-avfilter --enable-swresample \
--enable-protocol=file,pipe --enable-demuxer=mov,mp3,wav,amr,aac \
--enable-parser=aac,aac_latm,mpegaudio \
--enable-decoder=mp3,mp3float,mp3adu,mp3adufloat,mp3on4,mp3on4float,aac,aac_latm,amrnb,amrwb,pcm_s16le,pcm_s16be,pcm_u8,pcm_s24le,pcm_s32le,pcm_f32le,pcm_f64le \
--enable-encoder=pcm_s16le --enable-muxer=wav \
--enable-filter=aresample,pan,anull,aformat,atrim,asetpts,abuffer,abuffersink
nice -n 19 make -j1 ffmpeg ffprobe
```
Binary SHA256: ffmpeg `a2c81c9049a5e919d8f5ce9c246580f9f6cf8a38aaece8c79ed0fd893c35453e`; ffprobe `af8f5eb67ea87beca9ec7fb27f3355cf918fabee0d88a7db628bf511c691a680`. Both executed successfully on the Webhost; synthetic stereo WAV was decoded and split. This minimal build is for the PCM two-stage driver, not an assertion that legacy MP3 re-encoding, all media encodings, or real hour-long ASR has passed.
## Dify and TLS
Use the isolated App provisioned by `../followup-audio-dify/`, HTTPS only. On this host PHP cURL/NSS rejects the current certificate; native PHP OpenSSL verifies it successfully. Set the feature's explicit `HTTP_TRANSPORT=openssl_stream`, not TLS verification off and not automatic fallback. The controlled child process maintains parent authorization/lease heartbeats. Other application HTTP clients are unchanged.
The Dify App owns model/generation parameters. Configure both explicit stage protocols, expected models and binding revisions; changing App/default/provider configuration requires a new revision and preview verification. Keep extraction thinking unset locally and `EXTRACTION_RESPONSE_FORMAT=prompt_json`; no claim that local OpenAI response_format/max_tokens are transmitted through Dify.
## Release constraints
- `ENABLED=true` only with `PREVIEW_ONLY=true`, explicit `TEST_DIAGNOSIS_IDS`, fresh preview fingerprint, stable private encryption key; `AUDIO_VERIFIED=false`.
- Use an independent `followup-audio-preview` consumer, concurrency 1. Do not restart prescription workers, PHP, Dify or GPU services.
- Back up source/env/static assets and affected schema/data. Additive DDL must use a bounded session lock budget; prefer explicit INSTANT columns and fail instead of silently table-copying.
- Keep existing untracked production overlays. Coordinate the two existing auto-pull lock files; never reset/clean the live repository.
- Build with `vite build` only. Copy new hashed assets additively, retain old assets for active browsers, then atomically replace index.html; do not invoke the destructive release.mjs on the live directory.
- Rollback stops only the new consumer and disables preview, restores changed source/index/env with hashes, and retains additive tables/columns/audit. Never roll back the entire business database or rewrite remote master to undo a release.
- Keep API credentials, raw recordings, clinical snapshots and runtime state outside Git. Application cleanup does not imply Dify copies were deleted.
+6
View File
@@ -46,6 +46,12 @@
下一质量门槛:取得人工回听/标注金标准,先解决主体、问答肯否、模糊数量和未提及字段问题,再测短/中/一小时及混合口音;门槛通过前不得打开真实客服入口。部署回退脚本恢复源码,不撤销任何已写业务记录。 下一质量门槛:取得人工回听/标注金标准,先解决主体、问答肯否、模糊数量和未提及字段问题,再测短/中/一小时及混合口音;门槛通过前不得打开真实客服入口。部署回退脚本恢复源码,不撤销任何已写业务记录。
## 2026-10-09 受控预览配置
本轮用户限定:仅测试患者“张三(系统占号)医生无需面诊”的现有诊单1;不新建诊单、不采纳任何录音资料。新增PREVIEW_ONLY、TEST_DIAGNOSIS_IDS/TEST_ADMIN_IDS及独立preview_verified_fingerprint;完整AUDIO_VERIFIED保持false。预览来源永久标记,即使将来切普通模式也禁止Apply。上传/转写/提炼/角色及草稿属于本功能资料,诊单、日常记录、跟踪备注及采纳审计均不得写入。
Dify独立App和ASR sidecar使用既有HTTPS域名;新增speech2text默认,不改旧App和其他类型默认。两阶段通过显式dify/dify_chat协议接入;配置、严格TLS兼容方式和受控部署约束见 `deployment/followup-audio-runtime/README.md` 及 `deployment/followup-audio-dify/README.md`。实际上线状态以本轮release验收账本为准,代码合并不等于部署成功。
## 使用与数据规则 ## 使用与数据规则
- 入口:网页问诊列表→编辑患者→回访录音。PC/H5共用现有编辑组件;桌面客户端不增加入口。 - 入口:网页问诊列表→编辑患者→回访录音。PC/H5共用现有编辑组件;桌面客户端不增加入口。
+27
View File
@@ -2,6 +2,10 @@
# Only dify may reuse existing [prescription_ai] base/key. openai_audio needs MODEL; asr_then_llm needs both stage model/key/base triples. # Only dify may reuse existing [prescription_ai] base/key. openai_audio needs MODEL; asr_then_llm needs both stage model/key/base triples.
[followup_audio] [followup_audio]
ENABLED = false ENABLED = false
PREVIEW_ONLY = false
# IDs only. Empty or invalid diagnosis allowlist denies preview; admin list never replaces RBAC.
TEST_DIAGNOSIS_IDS =
TEST_ADMIN_IDS =
AUDIO_VERIFIED = false AUDIO_VERIFIED = false
VERIFIED_PROFILES = VERIFIED_PROFILES =
PROFILE = qwen PROFILE = qwen
@@ -66,3 +70,26 @@ OPENAI_EXTRACTION_MAX_TOKENS = 8192
# The local Qwen run exhausted 8192 tokens with thinking=true; false produced JSON, not a quality pass. # The local Qwen run exhausted 8192 tokens with thinking=true; false produced JSON, not a quality pass.
# QWEN_EXTRACTION_ENABLE_THINKING = false # QWEN_EXTRACTION_ENABLE_THINKING = false
# OPENAI_EXTRACTION_ENABLE_THINKING = false # OPENAI_EXTRACTION_ENABLE_THINKING = false
# Synthetic connection readiness only; never sets AUDIO_VERIFIED or allows clinical adoption.
QWEN_PREVIEW_VERIFIED_FINGERPRINT =
OPENAI_PREVIEW_VERIFIED_FINGERPRINT =
# Explicit per-stage protocol. No automatic fallback. Dify uses dedicated App keys only.
QWEN_ASR_PROTOCOL = openai
QWEN_ASR_BINDING_REVISION =
QWEN_EXTRACTION_PROTOCOL = openai
QWEN_EXTRACTION_BINDING_REVISION =
OPENAI_ASR_PROTOCOL = openai
OPENAI_ASR_BINDING_REVISION =
OPENAI_EXTRACTION_PROTOCOL = openai
OPENAI_EXTRACTION_BINDING_REVISION =
# For Dify: ASR_PROTOCOL=dify, EXTRACTION_PROTOCOL=dify_chat, RESPONSE_FORMAT=prompt_json.
# Every Dify stage needs a nonempty BINDING_REVISION. MODEL is expected identity, not sent.
# Dify App owns generation parameters: local max_tokens/response_format/thinking are NOT forwarded.
# Leave EXTRACTION_ENABLE_THINKING absent for Dify; set generation limits in the dedicated App.
# Changing that App requires a new binding revision and fresh preview fingerprint acceptance.
# asr_then_llm only: curl (default) or openssl_stream (explicit verified-TLS child process).
# No automatic transport fallback; changing this requires new fingerprint readiness.
HTTP_TRANSPORT = curl
@@ -7,6 +7,7 @@ namespace app\adminapi\logic\tcm;
use app\common\service\followupaudio\FollowupAudioAccess as Access; use app\common\service\followupaudio\FollowupAudioAccess as Access;
use app\common\service\followupaudio\FollowupAudioApply as Apply; use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioFields as Fields; use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioGate as Gate;
use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConfig; use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConfig;
use app\common\service\followupaudio\FollowupAudioStore as Store; use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioUpload as Upload; use app\common\service\followupaudio\FollowupAudioUpload as Upload;
@@ -16,15 +17,19 @@ final class FollowupAudioLogic
{ {
public static function capabilities(int $diagnosisId, int $actor, array $info): array public static function capabilities(int $diagnosisId, int $actor, array $info): array
{ {
$diagnosis = Access::diagnosis($diagnosisId, $actor, $info); $diagnosis = Access::diagnosis($diagnosisId, $actor, $info, false, false);
$enabled = Store::enabled(); $scope = Gate::scopeAllowed($diagnosisId, $actor);
$preview = Gate::previewOnly();
$ready = Store::ready();
$enabled = Store::enabled() && $scope;
$verified = Store::verified(); $verified = Store::verified();
$daily = Access::canDaily($actor, $info); $daily = Access::canDaily($actor, $info);
return [ return [
'enabled' => $enabled, 'audio_verified' => $verified, 'enabled' => $enabled, 'audio_verified' => $verified,
'can_upload' => $enabled && $verified, 'can_apply' => $enabled && $verified, 'preview_only' => $preview, 'preview_ready' => $preview && $ready, 'test_scope_allowed' => $scope,
'can_upload' => $enabled && $ready, 'can_review' => $enabled && $ready, 'can_apply' => $enabled && $verified && !$preview,
'can_daily' => $daily, 'limits' => Upload::limits(), 'can_daily' => $daily, 'limits' => Upload::limits(),
'models' => ProviderConfig::publicModels(), 'models' => $enabled ? ProviderConfig::readyModels() : [],
// No migration/dictionary dependency is introduced when the feature is OFF. // No migration/dictionary dependency is introduced when the feature is OFF.
'fields' => $enabled ? self::catalogForActor($diagnosis, $actor, $info) : [], 'fields' => $enabled ? self::catalogForActor($diagnosis, $actor, $info) : [],
]; ];
@@ -35,7 +40,7 @@ final class FollowupAudioLogic
if (!Store::enabled()) { if (!Store::enabled()) {
throw new DomainException('回访录音功能尚未启用'); throw new DomainException('回访录音功能尚未启用');
} }
if ($verified && !Store::verified()) { if ($verified && !Store::ready()) {
throw new DomainException('当前服务与模型的音频能力尚未验证,暂不接受真实录音或写入'); throw new DomainException('当前服务与模型的音频能力尚未验证,暂不接受真实录音或写入');
} }
} }
@@ -53,7 +58,7 @@ final class FollowupAudioLogic
if (!$date || $date->format('Y-m-d H:i:s') !== $p['recorded_at'] || $date->getTimestamp() > time() + 300) { if (!$date || $date->format('Y-m-d H:i:s') !== $p['recorded_at'] || $date->getTimestamp() > time() + 300) {
throw new DomainException('请填写正确的实际通话时间(北京时间),不能晚于当前时间'); throw new DomainException('请填写正确的实际通话时间(北京时间),不能晚于当前时间');
} }
if (!in_array($p['model_key'], ['qwen', 'openai'], true) || !Store::verified($p['model_key'])) { if (!in_array($p['model_key'], ['qwen', 'openai'], true) || !Store::ready($p['model_key'])) {
throw new DomainException('所选模型音频能力尚未验证'); throw new DomainException('所选模型音频能力尚未验证');
} }
return Store::create($upload, $p['recorded_at'], $p['model_key'], $actor, $info); return Store::create($upload, $p['recorded_at'], $p['model_key'], $actor, $info);
@@ -81,6 +86,7 @@ final class FollowupAudioLogic
public static function apply(int $id, int $version, array $items, int $actor, array $info): array public static function apply(int $id, int $version, array $items, int $actor, array $info): array
{ {
Gate::assertMayApply();
self::requireEnabled(true); self::requireEnabled(true);
$task = Access::task($id, $actor, $info); $task = Access::task($id, $actor, $info);
self::checkDailyItems($task, $items, $actor, $info); self::checkDailyItems($task, $items, $actor, $info);
+4 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\command; namespace app\command;
use app\common\service\followupaudio\FollowupAudioStore; use app\common\service\followupaudio\FollowupAudioStore;
use app\common\service\followupaudio\FollowupAudioGate;
use app\common\service\followupaudio\FollowupAudioWorker; use app\common\service\followupaudio\FollowupAudioWorker;
use think\console\Command; use think\console\Command;
use think\console\Input; use think\console\Input;
@@ -42,7 +43,9 @@ final class FollowupAudioWork extends Command
$worked = $worker->runOnce(); $worked = $worker->runOnce();
if ($input->getOption('once') || $worked) { if ($input->getOption('once') || $worked) {
$output->writeln('FOLLOWUP_AUDIO ' . json_encode(['enabled' => FollowupAudioStore::enabled(), $output->writeln('FOLLOWUP_AUDIO ' . json_encode(['enabled' => FollowupAudioStore::enabled(),
'audio_verified' => FollowupAudioStore::verified(), 'processed' => $worked])); 'audio_verified' => FollowupAudioStore::verified(),
'preview_only' => FollowupAudioGate::previewOnly(),
'preview_ready' => FollowupAudioGate::previewOnly() && FollowupAudioStore::ready(), 'processed' => $worked]));
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$output->writeln('FOLLOWUP_AUDIO storage_or_configuration_error'); $output->writeln('FOLLOWUP_AUDIO storage_or_configuration_error');
@@ -40,8 +40,9 @@ final class FollowupAudioAccess
return in_array(strtolower($permission), array_map('strtolower', $permissions), true); return in_array(strtolower($permission), array_map('strtolower', $permissions), true);
} }
public static function diagnosis(int $diagnosisId, int $actor, array $info, bool $daily = false): array public static function diagnosis(int $diagnosisId, int $actor, array $info, bool $daily = false, bool $enforcePreviewScope = true): array
{ {
if ($enforcePreviewScope) { FollowupAudioGate::assertScope($diagnosisId, $actor); }
if ($diagnosisId <= 0) { if ($diagnosisId <= 0) {
throw new DomainException('诊单不存在或无权操作'); throw new DomainException('诊单不存在或无权操作');
} }
@@ -17,6 +17,7 @@ final class FollowupAudioApply
public static function apply(int $taskId, int $version, array $items, int $actor, array $info): array public static function apply(int $taskId, int $version, array $items, int $actor, array $info): array
{ {
FollowupAudioGate::assertMayApply(); // Before DB, root checks and the already-applied idempotent path.
FollowupAudioStore::assertEnabled(); FollowupAudioStore::assertEnabled();
// Scope helpers perform ordinary reads. READ COMMITTED avoids a pre-lock actor/scope snapshot, // Scope helpers perform ordinary reads. READ COMMITTED avoids a pre-lock actor/scope snapshot,
// and SET TRANSACTION changes this one transaction only (never the connection/session default). // and SET TRANSACTION changes this one transaction only (never the connection/session default).
@@ -26,6 +27,7 @@ final class FollowupAudioApply
$connection->execute('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); $connection->execute('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
$result = Db::transaction(static function () use ($taskId, $version, $items, $actor, $info): array { $result = Db::transaction(static function () use ($taskId, $version, $items, $actor, $info): array {
$task = FollowupAudioStore::lockTask($taskId); $task = FollowupAudioStore::lockTask($taskId);
FollowupAudioGate::assertMayApply($task);
FollowupAudioStore::assertEnabled((string) $task['model_key']); FollowupAudioStore::assertEnabled((string) $task['model_key']);
$info = FollowupAudioAccess::actor($actor); $info = FollowupAudioAccess::actor($actor);
FollowupAudioAccess::task($taskId, $actor, $info); FollowupAudioAccess::task($taskId, $actor, $info);
@@ -93,6 +95,7 @@ final class FollowupAudioApply
if ($identityValues !== []) { self::assertIdentityMutable($diagnosis, $identityValues, $actor, $info); } if ($identityValues !== []) { self::assertIdentityMutable($diagnosis, $identityValues, $actor, $info); }
$applied = []; $applied = [];
foreach ($selected as $item) { foreach ($selected as $item) {
FollowupAudioGate::assertMayApply($task);
$kind = $item['kind']; $kind = $item['kind'];
$table = self::TABLES[$kind]; $table = self::TABLES[$kind];
$targetId = $kind === 'diagnosis' ? (int) $diagnosis['id'] : (int) ($item['target_id'] ?? 0); $targetId = $kind === 'diagnosis' ? (int) $diagnosis['id'] : (int) ($item['target_id'] ?? 0);
@@ -24,9 +24,10 @@ final class FollowupAudioDify
if (empty($this->settings['enabled'])) { if (empty($this->settings['enabled'])) {
throw new FollowupAudioException('FEATURE_DISABLED'); throw new FollowupAudioException('FEATURE_DISABLED');
} }
if (!FollowupAudioProviderConfig::verified((string) ($task['model_key'] ?? ''), $this->settings, $this->provider)) { if (!FollowupAudioGate::ready((string) ($task['model_key'] ?? ''), $this->settings, $this->provider)) {
throw new FollowupAudioException('AUDIO_NOT_VERIFIED'); throw new FollowupAudioException('AUDIO_NOT_VERIFIED');
} }
FollowupAudioGate::assertScope((int) ($task['diagnosis_id'] ?? 0), (int) ($task['actor_id'] ?? 0), $this->settings);
$resolved = FollowupAudioProviderConfig::resolve((string) $task['model_key'], $this->settings, $this->provider); $resolved = FollowupAudioProviderConfig::resolve((string) $task['model_key'], $this->settings, $this->provider);
if ($resolved['driver'] !== 'asr_then_llm' && (int) ($task['upstream_started_at'] ?? 0) > 0) { if ($resolved['driver'] !== 'asr_then_llm' && (int) ($task['upstream_started_at'] ?? 0) > 0) {
throw new FollowupAudioException('RECONCILIATION_REQUIRED', true); throw new FollowupAudioException('RECONCILIATION_REQUIRED', true);
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace app\common\service\followupaudio;
use DomainException;
/** Preview is a separate, ID-scoped recognition permission; never permission to write clinical facts. */
final class FollowupAudioGate
{
public static function previewOnly(?array $settings = null): bool
{
return (bool) (($settings ?? (array) config('followup_audio', []))['preview_only'] ?? false);
}
public static function scopeAllowed(int $diagnosisId, int $actor, ?array $settings = null): bool
{
$settings = $settings ?? (array) config('followup_audio', []);
if (!self::previewOnly($settings)) { return true; }
try { $diagnoses = self::ids($settings['test_diagnosis_ids'] ?? ''); $admins = self::ids($settings['test_admin_ids'] ?? ''); }
catch (DomainException $error) { return false; }
return $diagnosisId > 0 && $actor > 0 && $diagnoses !== [] && in_array($diagnosisId, $diagnoses, true)
&& ($admins === [] || in_array($actor, $admins, true));
}
private static function ids($raw): array
{
if (is_string($raw)) { $raw = trim($raw) === '' ? [] : explode(',', $raw); }
if (!is_array($raw) || !array_is_list($raw) || count($raw) > 1000) { throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_SCOPE_INVALID'); }
$ids = [];
foreach ($raw as $entry) {
if ((!is_int($entry) && !is_string($entry)) || !preg_match('/^[1-9][0-9]{0,17}$/D', trim((string) $entry))) {
throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_SCOPE_INVALID');
}
$ids[] = (int) trim((string) $entry);
}
return array_values(array_unique($ids));
}
public static function assertScope(int $diagnosisId, int $actor, ?array $settings = null): void
{
if (!self::scopeAllowed($diagnosisId, $actor, $settings)) { throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_SCOPE_DENIED'); }
}
public static function ready(?string $profile = null, ?array $settings = null, ?array $legacy = null): bool
{
$settings = $settings ?? (array) config('followup_audio', []); $legacy = $legacy ?? (array) config('prescription_ai', []);
if ($profile === null) {
foreach (['qwen', 'openai'] as $slot) { if (self::ready($slot, $settings, $legacy)) { return true; } }
return false;
}
return self::previewOnly($settings) ? FollowupAudioProviderConfig::previewVerified($profile, $settings, $legacy)
: FollowupAudioProviderConfig::verified($profile, $settings, $legacy);
}
/** Persisted preview-origin tasks never become eligible for adoption after a configuration change. */
public static function taskPreview(array $task): bool
{
$ids = json_decode((string) ($task['upstream_ids_json'] ?? '{}'), true);
if (!is_array($ids) || !array_key_exists('preview_only', $ids)) { return false; }
if ($ids['preview_only'] !== true) { throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_MARKER_INVALID'); }
return true;
}
public static function assertMayApply(?array $task = null): void
{
if (self::previewOnly() || ($task !== null && self::taskPreview($task))) { throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_ONLY'); }
}
}
@@ -71,13 +71,14 @@ final class FollowupAudioPipeline
continue; continue;
} }
$next = $state; $next = $state;
unset($next['chunks'][$index]['upstream_ids']);
$next['chunks'][$index]['state'] = 'intent'; $next['chunks'][$index]['state'] = 'intent';
$next['chunks'][$index]['request_id'] = 'fa-' . bin2hex(random_bytes(16)); $next['chunks'][$index]['request_id'] = 'fa-' . bin2hex(random_bytes(16));
$this->save($state, $next, $task, $heartbeat, 'transcribing', true); $this->save($state, $next, $task, $heartbeat, 'transcribing', true);
$response = $this->request('asr', ['multipart' => ['model' => $this->provider['asr']['model'], 'response_format' => 'json', $response = $this->request('asr', $this->transcriptionRequest($chunkPath, $state['chunks'][$index]['request_id']), $heartbeat);
'file' => new \CURLFile($chunkPath, 'audio/wav', 'chunk.wav')]], $heartbeat);
if ($response['rejected']) { if ($response['rejected']) {
$next = $state; $next['chunks'][$index]['state'] = 'rejected'; $next = $state; $next['chunks'][$index]['state'] = 'rejected';
if ($response['upstream_ids'] !== []) { $next['chunks'][$index]['upstream_ids'] = $response['upstream_ids']; }
$this->save($state, $next, $task, $heartbeat, 'transcribing'); $this->save($state, $next, $task, $heartbeat, 'transcribing');
throw new FollowupAudioException('ASR_REJECTED'); throw new FollowupAudioException('ASR_REJECTED');
} }
@@ -86,6 +87,7 @@ final class FollowupAudioPipeline
throw new FollowupAudioException('ASR_RESPONSE_INVALID', true); throw new FollowupAudioException('ASR_RESPONSE_INVALID', true);
} }
$next = $state; $next['chunks'][$index]['state'] = 'complete'; $next['chunks'][$index]['text'] = $body['text']; $next = $state; $next['chunks'][$index]['state'] = 'complete'; $next['chunks'][$index]['text'] = $body['text'];
if ($response['upstream_ids'] !== []) { $next['chunks'][$index]['upstream_ids'] = $response['upstream_ids']; }
$this->save($state, $next, $task, $heartbeat, 'transcribing'); $this->save($state, $next, $task, $heartbeat, 'transcribing');
unlink($chunkPath); unlink($chunkPath);
} }
@@ -93,23 +95,33 @@ final class FollowupAudioPipeline
$transcript = implode("\n", array_column($segments, 'text')); $transcript = implode("\n", array_column($segments, 'text'));
self::assertTranscriptQuality($transcript, $segments); self::assertTranscriptQuality($transcript, $segments);
if ($state['extraction']['state'] !== 'complete') { if ($state['extraction']['state'] !== 'complete') {
$payload = $this->extractRequest($transcript, $segments, $task['recorded_at'], FollowupAudioFields::catalog()); $requestId = 'fa-' . bin2hex(random_bytes(16));
$payload = $this->extractRequest($transcript, $segments, $task['recorded_at'], FollowupAudioFields::catalog(), $requestId);
self::beat($heartbeat, []); self::beat($heartbeat, []);
$this->assertSource($audio['path'], $task['sha256']); $this->assertSource($audio['path'], $task['sha256']);
$next = $state; $next['extraction'] = ['state' => 'intent', 'request_id' => 'fa-' . bin2hex(random_bytes(16))]; $next = $state; $next['extraction'] = ['state' => 'intent', 'request_id' => $requestId];
$this->save($state, $next, $task, $heartbeat, 'extracting', true); $this->save($state, $next, $task, $heartbeat, 'extracting', true);
$response = $this->request('extraction', ['json' => $payload], $heartbeat); $response = $this->request('extraction', ['json' => $payload], $heartbeat);
if ($response['rejected']) { if ($response['rejected']) {
$next = $state; $next['extraction']['state'] = 'rejected'; $next = $state; $next['extraction']['state'] = 'rejected';
if ($response['upstream_ids'] !== []) { $next['extraction']['upstream_ids'] = $response['upstream_ids']; }
$this->save($state, $next, $task, $heartbeat, 'extracting'); $this->save($state, $next, $task, $heartbeat, 'extracting');
throw new FollowupAudioException('EXTRACTION_REJECTED'); throw new FollowupAudioException('EXTRACTION_REJECTED');
} }
$choices = $response['body']['choices'] ?? []; if (($this->provider['extraction']['protocol'] ?? 'openai') === 'dify_chat') {
$choice = is_array($choices) && count($choices) === 1 ? ($choices[0] ?? []) : []; $body = $response['body'];
$answer = ($choice['finish_reason'] ?? '') === 'stop' && empty($choice['message']['refusal']) && empty($choice['message']['tool_calls']) $finish = $body['finish_reason'] ?? $body['metadata']['finish_reason'] ?? $body['metadata']['usage']['finish_reason'] ?? null;
&& is_string($choice['message']['content'] ?? null) ? $choice['message']['content'] : ''; $answer = ($finish === null || $finish === 'stop') && empty($body['refusal']) && empty($body['tool_calls'])
&& is_string($body['answer'] ?? null) ? $body['answer'] : '';
} else {
$choices = $response['body']['choices'] ?? [];
$choice = is_array($choices) && count($choices) === 1 ? ($choices[0] ?? []) : [];
$answer = ($choice['finish_reason'] ?? '') === 'stop' && empty($choice['message']['refusal']) && empty($choice['message']['tool_calls'])
&& is_string($choice['message']['content'] ?? null) ? $choice['message']['content'] : '';
}
// A received invalid answer is still a known completed request; retain it encrypted, never silently reissue it. // A received invalid answer is still a known completed request; retain it encrypted, never silently reissue it.
$next = $state; $next['extraction']['state'] = 'complete'; $next['extraction']['answer'] = $answer; $next = $state; $next['extraction']['state'] = 'complete'; $next['extraction']['answer'] = $answer;
if ($response['upstream_ids'] !== []) { $next['extraction']['upstream_ids'] = $response['upstream_ids']; }
$this->save($state, $next, $task, $heartbeat, 'validating'); $this->save($state, $next, $task, $heartbeat, 'validating');
} }
$this->assertSource($audio['path'], $task['sha256']); $this->assertSource($audio['path'], $task['sha256']);
@@ -127,7 +139,7 @@ final class FollowupAudioPipeline
} }
/** Pure production request builder for authorized cached-ASR acceptance; performs no network or state mutation. */ /** Pure production request builder for authorized cached-ASR acceptance; performs no network or state mutation. */
public function extractRequest(string $transcript, array $segments, string $recordedAt, array $catalog): array public function extractRequest(string $transcript, array $segments, string $recordedAt, array $catalog, ?string $requestId = null): array
{ {
$part = $this->provider['extraction']; $part = $this->provider['extraction'];
$mode = $part['response_format'] ?? 'json_schema'; $mode = $part['response_format'] ?? 'json_schema';
@@ -138,6 +150,11 @@ final class FollowupAudioPipeline
|| ($thinking !== null && !is_bool($thinking))) { throw new FollowupAudioException('CONFIG_INVALID'); } || ($thinking !== null && !is_bool($thinking))) { throw new FollowupAudioException('CONFIG_INVALID'); }
$prompt = FollowupAudioTranscriptPrompt::build($transcript, $segments, $recordedAt, $catalog); $prompt = FollowupAudioTranscriptPrompt::build($transcript, $segments, $recordedAt, $catalog);
self::assertTranscriptQuality($transcript, $segments); self::assertTranscriptQuality($transcript, $segments);
if (($part['protocol'] ?? 'openai') === 'dify_chat') {
if ($mode !== 'prompt_json' || empty($part['binding_revision']) || $thinking !== null) { throw new FollowupAudioException('CONFIG_INVALID'); }
return ['inputs' => new \stdClass(), 'query' => $prompt, 'response_mode' => 'blocking',
'user' => $this->opaqueUser($requestId ?? hash('sha256', $transcript)), 'auto_generate_name' => false];
}
$payload = ['model' => $part['model'], 'stream' => false, 'temperature' => 0, 'max_tokens' => $maxTokens, $payload = ['model' => $part['model'], 'stream' => false, 'temperature' => 0, 'max_tokens' => $maxTokens,
'messages' => [['role' => 'user', 'content' => $prompt]]]; 'messages' => [['role' => 'user', 'content' => $prompt]]];
if ($mode === 'json_schema') { $payload['response_format'] = self::buildResponseFormat($catalog, FollowupAudioTranscriptPrompt::citations($segments)); } if ($mode === 'json_schema') { $payload['response_format'] = self::buildResponseFormat($catalog, FollowupAudioTranscriptPrompt::citations($segments)); }
@@ -147,6 +164,20 @@ final class FollowupAudioPipeline
return $payload; return $payload;
} }
public function transcriptionRequest(string $chunkPath, string $requestId): array
{
$file = new \CURLFile($chunkPath, 'audio/wav', 'chunk.wav');
if (($this->provider['asr']['protocol'] ?? 'openai') === 'dify') {
return ['multipart' => ['file' => $file, 'user' => $this->opaqueUser($requestId)]];
}
return ['multipart' => ['model' => $this->provider['asr']['model'], 'response_format' => 'json', 'file' => $file]];
}
private function opaqueUser(string $requestId): string
{
return 'fa-opaque-' . substr(hash('sha256', $this->provider['fingerprint'] . ':' . $requestId), 0, 48);
}
public static function buildResponseFormat(array $catalog, array $citations): array public static function buildResponseFormat(array $catalog, array $citations): array
{ {
return FollowupAudioExtractionSchema::responseFormat($catalog, $citations); return FollowupAudioExtractionSchema::responseFormat($catalog, $citations);
@@ -265,19 +296,41 @@ final class FollowupAudioPipeline
$limit = (int) ($this->settings['max_response_bytes'] ?? 8388608); $limit = (int) ($this->settings['max_response_bytes'] ?? 8388608);
if ($timeout < 1 || $timeout > 300 || $limit < 1024 || $limit > 8388608) { throw new FollowupAudioException('CONFIG_INVALID'); } if ($timeout < 1 || $timeout > 300 || $limit < 1024 || $limit > 8388608) { throw new FollowupAudioException('CONFIG_INVALID'); }
$part = $this->provider[$stage]; $part = $this->provider[$stage];
$spec = ['url' => $part['base_url'] . ($stage === 'asr' ? '/audio/transcriptions' : '/chat/completions'), $protocol = $part['protocol'] ?? 'openai';
$endpoint = $stage === 'asr' ? ($protocol === 'dify' ? '/audio-to-text' : '/audio/transcriptions')
: ($protocol === 'dify_chat' ? '/chat-messages' : '/chat/completions');
$spec = ['url' => $part['base_url'] . $endpoint,
'api_key' => $part['api_key'], 'timeout' => $timeout, 'stage' => $stage] + $payload; 'api_key' => $part['api_key'], 'timeout' => $timeout, 'stage' => $stage] + $payload;
try { $response = $this->transport ? ($this->transport)($spec, $heartbeat) : $this->curl($spec, $heartbeat, $limit); } try {
$response = $this->transport ? ($this->transport)($spec, $heartbeat)
: (($this->provider['http_transport'] ?? 'curl') === 'openssl_stream'
? FollowupAudioStreamTransport::request($spec, $heartbeat, $limit, $this->provider['allow_loopback_tunnel'])
: $this->curl($spec, $heartbeat, $limit));
}
catch (\Throwable $e) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); } catch (\Throwable $e) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
$http = (int) ($response['http_code'] ?? 0); $http = (int) ($response['http_code'] ?? 0);
$candidate = is_string($response['body'] ?? null) && strlen($response['body']) <= $limit ? json_decode($response['body'], true) : null;
$ids = [];
if (is_array($candidate)) {
foreach (['task_id', 'message_id', 'conversation_id'] as $key) {
if (is_string($candidate[$key] ?? null) && preg_match('/^[A-Za-z0-9_.:-]{1,190}$/D', $candidate[$key])) { $ids[$key] = $candidate[$key]; }
}
if (!isset($ids['message_id']) && $stage === 'extraction' && is_string($candidate['id'] ?? null)
&& preg_match('/^[A-Za-z0-9_.:-]{1,190}$/D', $candidate['id'])) { $ids['message_id'] = $candidate['id']; }
}
if ($ids !== []) {
$fields = ['upstream_ids_json' => json_encode($ids, JSON_THROW_ON_ERROR)];
if (isset($ids['message_id']) || isset($ids['task_id'])) { $fields['upstream_run_id'] = $ids['message_id'] ?? $ids['task_id']; }
self::beat($heartbeat, $fields); // Preserve observed opaque IDs even when the reply is uncertain/rejected.
}
if (($response['errno'] ?? 0) !== 0 || $http === 0 || $http >= 500 || $http === 408 if (($response['errno'] ?? 0) !== 0 || $http === 0 || $http >= 500 || $http === 408
|| !is_string($response['body'] ?? null) || strlen($response['body']) > $limit) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); } || !is_string($response['body'] ?? null) || strlen($response['body']) > $limit) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
if (in_array($http, [400, 401, 403, 404, 413, 415, 422, 429], true)) { return ['rejected' => true, 'body' => []]; } if (in_array($http, [400, 401, 403, 404, 413, 415, 422, 429], true)) { return ['rejected' => true, 'body' => [], 'upstream_ids' => $ids]; }
if ($http < 200 || $http >= 300) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); } if ($http < 200 || $http >= 300) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
try { $body = json_decode($response['body'], true, 64, JSON_THROW_ON_ERROR); } try { $body = json_decode($response['body'], true, 64, JSON_THROW_ON_ERROR); }
catch (\Throwable $e) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); } catch (\Throwable $e) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
if (!is_array($body) || !empty($body['error'])) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); } if (!is_array($body) || !empty($body['error']) || !empty($body['code']) || ($body['event'] ?? '') === 'error') { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
return ['rejected' => false, 'body' => $body]; return ['rejected' => false, 'body' => $body, 'upstream_ids' => $ids];
} }
private function curl(array $spec, callable $heartbeat, int $limit): array private function curl(array $spec, callable $heartbeat, int $limit): array
@@ -71,8 +71,15 @@ final class FollowupAudioPipelineCheckpoint
private static function entry(array $entry, bool $extraction): void private static function entry(array $entry, bool $extraction): void
{ {
$allowed = $extraction ? ['state', 'request_id', 'answer'] : ['id', 'start_ms', 'end_ms', 'channel', 'state', 'request_id', 'text', 'source', 'pcm_sha256', 'pcm_bytes']; $allowed = $extraction ? ['state', 'request_id', 'answer', 'upstream_ids'] : ['id', 'start_ms', 'end_ms', 'channel', 'state', 'request_id', 'text', 'source', 'pcm_sha256', 'pcm_bytes', 'upstream_ids'];
if (array_diff(array_keys($entry), $allowed) || !in_array($entry['state'] ?? '', ['pending', 'intent', 'complete', 'rejected', 'local_silence'], true)) { self::invalid(); } if (array_diff(array_keys($entry), $allowed) || !in_array($entry['state'] ?? '', ['pending', 'intent', 'complete', 'rejected', 'local_silence'], true)) { self::invalid(); }
if (array_key_exists('upstream_ids', $entry)) {
if (!is_array($entry['upstream_ids']) || $entry['upstream_ids'] === [] || !in_array($entry['state'], ['complete', 'rejected'], true)) { self::invalid(); }
foreach ($entry['upstream_ids'] as $key => $value) {
if (!in_array($key, ['task_id', 'message_id', 'conversation_id'], true) || !is_string($value)
|| !preg_match('/^[A-Za-z0-9_.:-]{1,190}$/D', $value)) { self::invalid(); }
}
}
if (array_key_exists('channel', $entry) && (!is_int($entry['channel']) || !in_array($entry['channel'], [0, 1], true))) { self::invalid(); } if (array_key_exists('channel', $entry) && (!is_int($entry['channel']) || !in_array($entry['channel'], [0, 1], true))) { self::invalid(); }
if ($entry['state'] === 'local_silence') { if ($entry['state'] === 'local_silence') {
if ($extraction || array_key_exists('request_id', $entry) || ($entry['text'] ?? null) !== '' || ($entry['source'] ?? '') !== 'local_silence' if ($extraction || array_key_exists('request_id', $entry) || ($entry['text'] ?? null) !== '' || ($entry['source'] ?? '') !== 'local_silence'
@@ -14,6 +14,8 @@ final class FollowupAudioProviderConfig
$legacy = $legacy ?? (array) config('prescription_ai', []); $legacy = $legacy ?? (array) config('prescription_ai', []);
$provider = (array) ($settings['providers'][$profile] ?? []); $provider = (array) ($settings['providers'][$profile] ?? []);
$driver = (string) ($provider['driver'] ?? 'dify'); $driver = (string) ($provider['driver'] ?? 'dify');
$httpTransport = $settings['http_transport'] ?? 'curl';
if (!in_array($httpTransport, ['curl', 'openssl_stream'], true) || ($driver !== 'asr_then_llm' && $httpTransport !== 'curl')) { throw new FollowupAudioException('CONFIG_INVALID'); }
if (!in_array($driver, ['dify', 'openai_audio', 'asr_then_llm'], true)) { throw new FollowupAudioException('CONFIG_INVALID'); } if (!in_array($driver, ['dify', 'openai_audio', 'asr_then_llm'], true)) { throw new FollowupAudioException('CONFIG_INVALID'); }
if ($driver === 'asr_then_llm') { return self::pipeline($provider, $settings, $profile); } if ($driver === 'asr_then_llm') { return self::pipeline($provider, $settings, $profile); }
$base = (string) ($provider['base_url'] ?? ''); $base = (string) ($provider['base_url'] ?? '');
@@ -80,6 +82,29 @@ final class FollowupAudioProviderConfig
&& hash_equals($provider['fingerprint'], $verified); && hash_equals($provider['fingerprint'], $verified);
} }
/** Synthetic connection readiness is not the full audio/accuracy acceptance gate. */
public static function previewVerified(string $profile, ?array $settings = null, ?array $legacy = null): bool
{
$settings = $settings ?? (array) config('followup_audio', []);
$legacy = $legacy ?? (array) config('prescription_ai', []);
try { $provider = self::resolve($profile, $settings, $legacy); }
catch (FollowupAudioException $error) { return false; }
$fingerprint = (string) ($settings['providers'][$profile]['preview_verified_fingerprint'] ?? '');
$secure = $provider['driver'] === 'asr_then_llm'
? self::secureEndpoint($provider['asr']['base_url'], $provider['allow_loopback_tunnel']) && self::secureEndpoint($provider['extraction']['base_url'], $provider['allow_loopback_tunnel'])
: str_starts_with($provider['base_url'], 'https://');
return $secure && preg_match('/^[a-f0-9]{64}$/D', $fingerprint) && hash_equals($provider['fingerprint'], $fingerprint);
}
public static function readyModels(): array
{
$models = [];
foreach (['qwen', 'openai'] as $profile) {
if (FollowupAudioGate::ready($profile)) { $models[] = ['value' => $profile, 'label' => self::resolve($profile)['label']]; }
}
return $models;
}
/** Literal loopback only, explicitly configured for a locally established SSH tunnel. No DNS exception. */ /** Literal loopback only, explicitly configured for a locally established SSH tunnel. No DNS exception. */
public static function secureEndpoint(string $base, bool $allowLoopback): bool public static function secureEndpoint(string $base, bool $allowLoopback): bool
{ {
@@ -94,15 +119,30 @@ final class FollowupAudioProviderConfig
$resolved = ['driver' => 'asr_then_llm', 'allow_loopback_tunnel' => $allowLoopback]; $resolved = ['driver' => 'asr_then_llm', 'allow_loopback_tunnel' => $allowLoopback];
foreach (['asr' => '/audio/transcriptions', 'extraction' => '/chat/completions'] as $stage => $endpoint) { foreach (['asr' => '/audio/transcriptions', 'extraction' => '/chat/completions'] as $stage => $endpoint) {
$input = (array) ($provider[$stage] ?? []); $input = (array) ($provider[$stage] ?? []);
// Use existing endpoint/key/model syntax validation without inheriting any legacy service. $protocol = $input['protocol'] ?? 'openai';
$part = self::resolve($profile, ['providers' => [$profile => array_merge($input, ['driver' => 'openai_audio'])]], []); $allowed = $stage === 'asr' ? ['openai', 'dify'] : ['openai', 'dify_chat'];
if (!in_array($protocol, $allowed, true)) { throw new FollowupAudioException('CONFIG_INVALID'); }
$revision = $input['binding_revision'] ?? '';
if ($protocol !== 'openai' && (!is_string($revision) || !preg_match('/^[A-Za-z0-9_.:-]{1,128}$/D', $revision))) {
throw new FollowupAudioException('CONFIG_INVALID');
}
$checked = $input;
if ($protocol !== 'openai') {
$endpoint = $stage === 'asr' ? '/audio-to-text' : '/chat-messages';
$original = rtrim((string) ($input['base_url'] ?? ''), '/');
if (str_ends_with($original, $endpoint)) { $checked['base_url'] = substr($original, 0, -strlen($endpoint)); }
}
// Model is a bound expected deployment identity for Dify, not a fake request parameter.
$part = self::resolve($profile, ['providers' => [$profile => array_merge($checked, ['driver' => 'openai_audio'])]], []);
$base = $part['base_url']; $base = $part['base_url'];
if ($stage === 'asr') { if ($stage === 'asr' && $protocol === 'openai') {
$original = rtrim((string) ($input['base_url'] ?? ''), '/'); $original = rtrim((string) ($input['base_url'] ?? ''), '/');
if (str_ends_with($original, $endpoint)) { $base = substr($original, 0, -strlen($endpoint)); } if (str_ends_with($original, $endpoint)) { $base = substr($original, 0, -strlen($endpoint)); }
} }
if (!self::secureEndpoint($base, $allowLoopback)) { throw new FollowupAudioException('HTTPS_REQUIRED'); } if (!self::secureEndpoint($base, $allowLoopback)) { throw new FollowupAudioException('HTTPS_REQUIRED'); }
$resolved[$stage] = ['base_url' => $base, 'api_key' => $part['api_key'], 'model' => $part['model']]; $resolved[$stage] = ['base_url' => $base, 'api_key' => $part['api_key'], 'model' => $part['model']];
// Default/explicit OpenAI retains its previous exact fingerprint representation.
if ($protocol !== 'openai') { $resolved[$stage] += ['protocol' => $protocol, 'binding_revision' => $revision]; }
} }
$chunkSeconds = (int) ($settings['asr_chunk_seconds'] ?? 120); $chunkSeconds = (int) ($settings['asr_chunk_seconds'] ?? 120);
if ($chunkSeconds < 1 || $chunkSeconds > 120) { throw new FollowupAudioException('CONFIG_INVALID'); } if ($chunkSeconds < 1 || $chunkSeconds > 120) { throw new FollowupAudioException('CONFIG_INVALID'); }
@@ -118,7 +158,11 @@ final class FollowupAudioProviderConfig
if (!in_array($mode, ['json_schema', 'json_object', 'prompt_json'], true) if (!in_array($mode, ['json_schema', 'json_object', 'prompt_json'], true)
|| !is_int($maxTokens) || $maxTokens < 256 || $maxTokens > 8192 || !is_int($maxTokens) || $maxTokens < 256 || $maxTokens > 8192
|| ($thinking !== null && !is_bool($thinking))) { throw new FollowupAudioException('CONFIG_INVALID'); } || ($thinking !== null && !is_bool($thinking))) { throw new FollowupAudioException('CONFIG_INVALID'); }
$resolved['extraction'] += ['response_format' => $mode, 'max_tokens' => $maxTokens, 'enable_thinking' => $thinking]; if (($resolved['extraction']['protocol'] ?? 'openai') === 'dify_chat') {
if (($provider['extraction']['response_format'] ?? null) !== 'prompt_json' || $thinking !== null) { throw new FollowupAudioException('CONFIG_INVALID'); }
$resolved['extraction']['response_format'] = 'prompt_json';
// Dify App owns model generation parameters. Local max_tokens is not transmitted or claimed effective.
} else { $resolved['extraction'] += ['response_format' => $mode, 'max_tokens' => $maxTokens, 'enable_thinking' => $thinking]; }
$resolved['output_schema'] = FollowupAudioExtractionSchema::VERSION; $resolved['output_schema'] = FollowupAudioExtractionSchema::VERSION;
$resolved['citation_policy'] = FollowupAudioTranscriptPrompt::CITATION_POLICY; $resolved['citation_policy'] = FollowupAudioTranscriptPrompt::CITATION_POLICY;
$resolved['schema_dialect'] = FollowupAudioExtractionSchema::DIALECT; $resolved['schema_dialect'] = FollowupAudioExtractionSchema::DIALECT;
@@ -126,8 +170,13 @@ final class FollowupAudioProviderConfig
if ($resolved['label'] === '' || strlen($resolved['label']) > 200 || preg_match('/[\x00-\x1f\x7f]/', $resolved['label'])) { if ($resolved['label'] === '' || strlen($resolved['label']) > 200 || preg_match('/[\x00-\x1f\x7f]/', $resolved['label'])) {
throw new FollowupAudioException('CONFIG_INVALID'); throw new FollowupAudioException('CONFIG_INVALID');
} }
$resolved['fingerprint'] = hash('sha256', json_encode([$resolved['driver'], $resolved['asr'], $resolved['extraction'], $identity = [$resolved['driver'], $resolved['asr'], $resolved['extraction'],
$allowLoopback, $chunkSeconds, 'pcm-s16le-mono-16000-v1', $resolved['output_schema'], $resolved['citation_policy'], $resolved['schema_dialect'], $resolved['channel_policy'], $stereoSeconds, $resolved['silence_policy'], 'checkpoint-v2'], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); $allowLoopback, $chunkSeconds, 'pcm-s16le-mono-16000-v1', $resolved['output_schema'], $resolved['citation_policy'], $resolved['schema_dialect'], $resolved['channel_policy'], $stereoSeconds, $resolved['silence_policy'], 'checkpoint-v2'];
if (($settings['http_transport'] ?? 'curl') !== 'curl') {
$resolved['http_transport'] = 'openssl_stream';
$identity[] = 'openssl-stream-child-v1';
}
$resolved['fingerprint'] = hash('sha256', json_encode($identity, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
return $resolved; return $resolved;
} }
@@ -18,6 +18,8 @@ final class FollowupAudioStore
return $profile === null ? self::verifiedProfiles() !== [] : FollowupAudioProviderConfig::isVerified($profile); return $profile === null ? self::verifiedProfiles() !== [] : FollowupAudioProviderConfig::isVerified($profile);
} }
public static function ready(?string $profile = null): bool { return FollowupAudioGate::ready($profile); }
private static function verifiedProfiles(): array private static function verifiedProfiles(): array
{ {
return array_values(array_filter(['qwen', 'openai'], [FollowupAudioProviderConfig::class, 'isVerified'])); return array_values(array_filter(['qwen', 'openai'], [FollowupAudioProviderConfig::class, 'isVerified']));
@@ -43,7 +45,7 @@ final class FollowupAudioStore
public static function assertEnabled(?string $profile = null): void public static function assertEnabled(?string $profile = null): void
{ {
if (!self::enabled() || !self::verified($profile)) { throw new DomainException('FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED'); } if (!self::enabled() || !self::ready($profile)) { throw new DomainException('FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED'); }
} }
public static function create(array $upload, string $recordedAt, string $modelKey, int $actor, array $info): array public static function create(array $upload, string $recordedAt, string $modelKey, int $actor, array $info): array
@@ -89,7 +91,7 @@ final class FollowupAudioStore
'lease_token' => '', 'lease_until' => 0, 'upstream_started_at' => 0, 'lease_token' => '', 'lease_until' => 0, 'upstream_started_at' => 0,
'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => FollowupAudioPolicy::canonical([ 'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => FollowupAudioPolicy::canonical([
'provider_fingerprint' => FollowupAudioProviderConfig::resolve($modelKey)['fingerprint'], 'provider_fingerprint' => FollowupAudioProviderConfig::resolve($modelKey)['fingerprint'],
]), ] + (FollowupAudioGate::previewOnly() ? ['preview_only' => true] : [])),
'error_code' => '', 'error_message' => '', 'extraction_cipher' => '', 'review_cipher' => '', 'applied_cipher' => '', 'error_code' => '', 'error_message' => '', 'extraction_cipher' => '', 'review_cipher' => '', 'applied_cipher' => '',
'created_at' => $now, 'updated_at' => $now, 'expires_at' => $expiresAt, 'applied_at' => 0, 'purged_at' => 0, 'created_at' => $now, 'updated_at' => $now, 'expires_at' => $expiresAt, 'applied_at' => 0, 'purged_at' => 0,
]); ]);
@@ -149,7 +151,9 @@ final class FollowupAudioStore
$result['error_code'] = 'FOLLOWUP_AUDIO_EXPIRED'; $result['error_code'] = 'FOLLOWUP_AUDIO_EXPIRED';
$result['error_message'] = '录音及审阅已到保留期限,不能采用'; $result['error_message'] = '录音及审阅已到保留期限,不能采用';
} }
$result['can_retry'] = self::enabled() && self::verified((string) $task['model_key']) && $alive && $task['status'] === 'failed' $result['preview_only'] = FollowupAudioGate::previewOnly() || FollowupAudioGate::taskPreview($task);
$result['can_retry'] = self::enabled() && self::ready((string) $task['model_key'])
&& FollowupAudioGate::scopeAllowed((int) $task['diagnosis_id'], (int) $task['actor_id']) && $alive && $task['status'] === 'failed'
&& self::providerMatches($task) && self::safeToResume($task) && (int) $task['attempts'] < 3; && self::providerMatches($task) && self::safeToResume($task) && (int) $task['attempts'] < 3;
$pipeline = $alive ? self::pipelineState($task) : []; $pipeline = $alive ? self::pipelineState($task) : [];
$segments = $pipeline ? FollowupAudioPipelineCheckpoint::segments($pipeline) : []; $segments = $pipeline ? FollowupAudioPipelineCheckpoint::segments($pipeline) : [];
@@ -233,6 +237,7 @@ final class FollowupAudioStore
$task = self::lockTask($taskId); $task = self::lockTask($taskId);
self::assertEnabled((string) $task['model_key']); self::assertEnabled((string) $task['model_key']);
self::assertTaskProvider($task); self::assertTaskProvider($task);
FollowupAudioGate::assertScope((int) $task['diagnosis_id'], (int) $task['actor_id']);
if ($task['status'] !== 'failed' || !self::safeToResume($task) || (int) $task['attempts'] >= 3 if ($task['status'] !== 'failed' || !self::safeToResume($task) || (int) $task['attempts'] >= 3
|| (int) $task['expires_at'] <= time() || (int) $task['purged_at']) { || (int) $task['expires_at'] <= time() || (int) $task['purged_at']) {
throw new DomainException('FOLLOWUP_AUDIO_RETRY_NOT_SAFE'); throw new DomainException('FOLLOWUP_AUDIO_RETRY_NOT_SAFE');
@@ -272,6 +277,7 @@ final class FollowupAudioStore
->where('expires_at', '>', $now)->where('purged_at', 0)->order('id', 'asc')->limit(200)->lock(true)->select()->toArray(); ->where('expires_at', '>', $now)->where('purged_at', 0)->order('id', 'asc')->limit(200)->lock(true)->select()->toArray();
$task = null; $task = null;
foreach ($pending as $candidate) { foreach ($pending as $candidate) {
if (!FollowupAudioGate::scopeAllowed((int) $candidate['diagnosis_id'], (int) $candidate['actor_id'])) { continue; }
if (!self::safeToResume($candidate)) { if (!self::safeToResume($candidate)) {
Db::name('followup_audio_task')->where('id', $candidate['id'])->update([ Db::name('followup_audio_task')->where('id', $candidate['id'])->update([
'status' => 'needs_reconciliation', 'stage' => 'needs_reconciliation', 'status' => 'needs_reconciliation', 'stage' => 'needs_reconciliation',
@@ -289,7 +295,7 @@ final class FollowupAudioStore
]); ]);
continue; continue;
} }
if (self::verified((string) $candidate['model_key'])) { $task = $candidate; break; } if (self::ready((string) $candidate['model_key'])) { $task = $candidate; break; }
} }
if ($task === null) { return null; } if ($task === null) { return null; }
$changes = ['status' => 'running', 'stage' => 'preparing', 'lease_token' => bin2hex(random_bytes(32)), $changes = ['status' => 'running', 'stage' => 'preparing', 'lease_token' => bin2hex(random_bytes(32)),
@@ -339,6 +345,10 @@ final class FollowupAudioStore
if (!is_array($ids)) { throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID'); } if (!is_array($ids)) { throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID'); }
$previous = json_decode($task['upstream_ids_json'] ?: '{}', true, 16, JSON_THROW_ON_ERROR); $previous = json_decode($task['upstream_ids_json'] ?: '{}', true, 16, JSON_THROW_ON_ERROR);
foreach ($ids as $name => $identifier) { foreach ($ids as $name => $identifier) {
if ($name === 'preview_only') {
if (($previous[$name] ?? null) !== true || $identifier !== true) { throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID'); }
continue;
}
if ($name === 'provider_fingerprint') { if ($name === 'provider_fingerprint') {
if (!is_string($identifier) || !is_string($previous[$name] ?? null) if (!is_string($identifier) || !is_string($previous[$name] ?? null)
|| !hash_equals($previous[$name], $identifier)) { || !hash_equals($previous[$name], $identifier)) {
@@ -547,7 +557,8 @@ final class FollowupAudioStore
private static function hasLease(array $task, string $token, bool $processing = true): bool private static function hasLease(array $task, string $token, bool $processing = true): bool
{ {
return (!$processing || self::enabled() && self::verified((string) $task['model_key']) && self::providerMatches($task)) return (!$processing || self::enabled() && self::ready((string) $task['model_key']) && self::providerMatches($task)
&& FollowupAudioGate::scopeAllowed((int) $task['diagnosis_id'], (int) $task['actor_id']))
&& $task['status'] === 'running' && $token !== '' && $task['status'] === 'running' && $token !== ''
&& hash_equals((string) $task['lease_token'], $token) && (int) $task['lease_until'] > time() && hash_equals((string) $task['lease_token'], $token) && (int) $task['lease_until'] > time()
&& (int) $task['expires_at'] > time() && !(int) $task['purged_at']; && (int) $task['expires_at'] > time() && !(int) $task['purged_at'];
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace app\common\service\followupaudio;
/** Explicit native-OpenSSL transport. Blocking headers are isolated from the parent's lease/scope heartbeats. */
final class FollowupAudioStreamTransport
{
public static function request(array $spec, callable $heartbeat, int $limit, bool $allowLoopback): array
{
$failure = static fn (int $errno): array => ['http_code' => 0, 'errno' => $errno, 'body' => ''];
$input = $spec;
if (isset($input['json'])) { $input['json_wire'] = json_encode($input['json'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); unset($input['json']); }
$input['max_response_bytes'] = $limit; $input['allow_loopback_tunnel'] = $allowLoopback;
if (isset($input['multipart']['file'])) {
$file = $input['multipart']['file'];
if (!$file instanceof \CURLFile) { return $failure(43); }
$input['multipart']['file'] = ['path' => $file->getFilename(), 'mime' => $file->getMimeType(), 'name' => $file->getPostFilename()];
}
$wire = json_encode($input, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (strlen($wire) > 16777216 || $limit < 1024 || $limit > 8388608 || ($spec['timeout'] ?? 0) < 1 || $spec['timeout'] > 300) { return $failure(43); }
try { if ($heartbeat([]) !== true) { return $failure(42); } } catch (\Throwable $e) { return $failure(42); }
$process = @proc_open([PHP_BINARY, __FILE__, '--child'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) { return $failure(7); }
foreach ($pipes as $pipe) { stream_set_blocking($pipe, false); }
$offset = 0; $output = ''; $deadline = microtime(true) + (int) $spec['timeout']; $lastHeartbeat = microtime(true); $exit = -1;
try {
do {
if (isset($pipes[0])) {
$written = @fwrite($pipes[0], substr($wire, $offset, 65536));
if ($written === false) { return $failure(7); }
$offset += $written;
if ($offset === strlen($wire)) { fclose($pipes[0]); unset($pipes[0]); }
}
$output .= (string) stream_get_contents($pipes[1], 65536);
stream_get_contents($pipes[2], 65536); // Raw child diagnostics may contain sensitive paths; never expose them.
if (strlen($output) > (int) ceil($limit * 4 / 3) + 65536) { return $failure(23); }
$status = proc_get_status($process);
if (!$status['running']) { $exit = (int) $status['exitcode']; break; }
if (microtime(true) >= $deadline) { return $failure(28); }
if (microtime(true) - $lastHeartbeat >= 5) {
try { $allowed = $heartbeat([]) === true; } catch (\Throwable $e) { $allowed = false; }
if (!$allowed) { return $failure(42); }
$lastHeartbeat = microtime(true);
}
usleep(10000);
} while (true);
$output .= (string) stream_get_contents($pipes[1]);
if ($exit !== 0 || strlen($output) > (int) ceil($limit * 4 / 3) + 65536) { return $failure(7); }
$result = json_decode($output, true);
if (!is_array($result) || !is_string($result['body_base64'] ?? null)) { return $failure(7); }
$body = base64_decode($result['body_base64'], true);
if ($body === false || strlen($body) > $limit) { return $failure(23); }
return ['http_code' => (int) ($result['http_code'] ?? 0), 'errno' => (int) ($result['errno'] ?? 7), 'body' => $body];
} finally {
if (is_resource($process)) { $status = proc_get_status($process); if ($status['running']) { proc_terminate($process, 9); } }
foreach ($pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } }
if (is_resource($process)) { proc_close($process); }
}
}
/** CLI child reads a private specification from stdin only: never credentials in argv or logs. */
private static function child(array $spec): array
{
$result = ['http_code' => 0, 'errno' => 43, 'body_base64' => ''];
$url = $spec['url'] ?? ''; $parts = is_string($url) ? parse_url($url) : false;
$stage = $spec['stage'] ?? ''; $timeout = $spec['timeout'] ?? 0; $limit = $spec['max_response_bytes'] ?? 0;
if (!is_array($parts) || !in_array($stage, ['asr', 'extraction'], true) || !is_int($timeout) || $timeout < 1 || $timeout > 300
|| !is_int($limit) || $limit < 1024 || $limit > 8388608 || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])
|| isset($parts['query']) || isset($parts['fragment']) || preg_match('/[\x00-\x20\x7f\\\\]/', $url)
|| !is_string($spec['api_key'] ?? null) || $spec['api_key'] === '' || preg_match('/[\x00-\x20\x7f]/', $spec['api_key'])) { return $result; }
$secure = ($parts['scheme'] ?? '') === 'https';
$loopback = ($spec['allow_loopback_tunnel'] ?? false) === true && ($parts['scheme'] ?? '') === 'http'
&& in_array($parts['host'], ['127.0.0.1', '[::1]'], true);
if (!$secure && !$loopback) { return $result; }
$allowed = $stage === 'asr' ? ['/audio-to-text', '/audio/transcriptions'] : ['/chat-messages', '/chat/completions'];
$matches = array_filter($allowed, static fn (string $suffix): bool => str_ends_with((string) ($parts['path'] ?? ''), $suffix));
if ($matches === []) { return $result; }
$headers = ['Accept: application/json', 'Authorization: Bearer ' . $spec['api_key'], 'Connection: close'];
if (isset($spec['json_wire']) && !isset($spec['multipart']) && $stage === 'extraction' && is_string($spec['json_wire'])
&& is_object(json_decode($spec['json_wire']))) {
$body = $spec['json_wire'];
$headers[] = 'Content-Type: application/json';
} elseif (isset($spec['multipart']) && !isset($spec['json_wire']) && $stage === 'asr' && is_array($spec['multipart'])) {
$multipart = $spec['multipart']; $file = $multipart['file'] ?? null;
if (!is_array($file) || !is_string($file['path'] ?? null) || !is_file($file['path']) || is_link($file['path'])
|| !is_readable($file['path']) || filesize($file['path']) < 44 || filesize($file['path']) > 8388608
|| ($file['mime'] ?? '') !== 'audio/wav' || ($file['name'] ?? '') !== 'chunk.wav'
|| array_diff(array_keys($multipart), ['file', 'user', 'model', 'response_format'])) { return $result; }
$audio = file_get_contents($file['path']);
if (!is_string($audio) || substr($audio, 0, 4) !== 'RIFF' || substr($audio, 8, 4) !== 'WAVE') { return $result; }
$boundary = 'fa-' . bin2hex(random_bytes(16)); $body = '';
foreach ($multipart as $name => $value) {
if ($name === 'file') { continue; }
if (!is_string($value) || strlen($value) > 256 || preg_match('/[\x00-\x20\x7f]/', $value)) { return $result; }
$body .= '--' . $boundary . "\r\nContent-Disposition: form-data; name=\"" . $name . "\"\r\n\r\n" . $value . "\r\n";
}
$body .= '--' . $boundary . "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"chunk.wav\"\r\nContent-Type: audio/wav\r\n\r\n" . $audio . "\r\n--" . $boundary . "--\r\n";
$headers[] = 'Content-Type: multipart/form-data; boundary=' . $boundary;
} else { return $result; }
if (strlen($body) > 16777216) { return $result; }
$headers[] = 'Content-Length: ' . strlen($body);
$context = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $body,
'timeout' => $timeout, 'ignore_errors' => true, 'follow_location' => 0, 'max_redirects' => 0, 'protocol_version' => 1.1],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true, 'allow_self_signed' => false, 'peer_name' => trim($parts['host'], '[]'), 'SNI_enabled' => true]]);
$stream = @fopen($url, 'rb', false, $context);
if (!is_resource($stream)) { $result['errno'] = 35; return $result; }
try {
foreach ($http_response_header ?? [] as $header) {
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $match)) { $result['http_code'] = (int) $match[1]; }
}
$response = '';
while (!feof($stream)) {
$bytes = @fread($stream, min(65536, $limit - strlen($response) + 1));
if ($bytes === false) { $result['errno'] = 56; return $result; }
$response .= $bytes;
if (strlen($response) > $limit) { $result['errno'] = 23; return $result; }
if (stream_get_meta_data($stream)['timed_out']) { $result['errno'] = 28; return $result; }
if ($bytes === '' && !feof($stream)) { usleep(10000); }
}
$result['errno'] = 0; $result['body_base64'] = base64_encode($response); return $result;
} finally { fclose($stream); }
}
public static function childMain(): void
{
$result = ['http_code' => 0, 'errno' => 43, 'body_base64' => ''];
try {
$wire = stream_get_contents(STDIN, 16777217);
$spec = is_string($wire) && strlen($wire) <= 16777216 ? json_decode($wire, true) : null;
if (is_array($spec)) { $result = self::child($spec); }
} catch (\Throwable $error) { /* Do not emit request data or native TLS diagnostics. */ }
echo json_encode($result, JSON_THROW_ON_ERROR);
}
}
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === '--child' && realpath((string) ($argv[0] ?? '')) === __FILE__) {
FollowupAudioStreamTransport::childMain();
}
@@ -25,10 +25,10 @@ final class FollowupAudioWorker
$started = (int) ($task['upstream_started_at'] ?? 0) > 0; $started = (int) ($task['upstream_started_at'] ?? 0) > 0;
try { try {
FollowupAudioStore::assertTaskProvider($task); FollowupAudioStore::assertTaskProvider($task);
if (!FollowupAudioStore::verified((string) $task['model_key'])) { throw new FollowupAudioException('AUDIO_NOT_VERIFIED'); } if (!FollowupAudioStore::ready((string) $task['model_key'])) { throw new FollowupAudioException('AUDIO_NOT_VERIFIED'); }
$heartbeat = function (array $fields = []) use ($task, $id, $token, &$started): bool { $heartbeat = function (array $fields = []) use ($task, $id, $token, &$started): bool {
FollowupAudioStore::assertTaskProvider($task); FollowupAudioStore::assertTaskProvider($task);
if (!FollowupAudioStore::enabled() || !FollowupAudioStore::verified((string) $task['model_key'])) { return false; } if (!FollowupAudioStore::enabled() || !FollowupAudioStore::ready((string) $task['model_key'])) { return false; }
$actor = PrescriptionAiAccess::actor((int) $task['actor_id']); $actor = PrescriptionAiAccess::actor((int) $task['actor_id']);
if (!$actor) { return false; } if (!$actor) { return false; }
FollowupAudioAccess::task($id, (int) $task['actor_id'], $actor); FollowupAudioAccess::task($id, (int) $task['actor_id'], $actor);
+10
View File
@@ -2,6 +2,10 @@
/** Audio is deliberately OFF until the synthetic short/15m/1h gate passes for the current app. */ /** Audio is deliberately OFF until the synthetic short/15m/1h gate passes for the current app. */
return [ return [
'preview_only' => filter_var(env('followup_audio.PREVIEW_ONLY', false), FILTER_VALIDATE_BOOLEAN),
// Parse strictly at the gate; empty/malformed diagnosis lists never grant preview access.
'test_diagnosis_ids' => env('followup_audio.TEST_DIAGNOSIS_IDS', ''),
'test_admin_ids' => env('followup_audio.TEST_ADMIN_IDS', ''),
'enabled' => filter_var(env('followup_audio.ENABLED', false), FILTER_VALIDATE_BOOLEAN), 'enabled' => filter_var(env('followup_audio.ENABLED', false), FILTER_VALIDATE_BOOLEAN),
'audio_verified' => filter_var(env('followup_audio.AUDIO_VERIFIED', false), FILTER_VALIDATE_BOOLEAN), 'audio_verified' => filter_var(env('followup_audio.AUDIO_VERIFIED', false), FILTER_VALIDATE_BOOLEAN),
// Each selected application must separately pass all three synthetic fixtures. // Each selected application must separately pass all three synthetic fixtures.
@@ -15,6 +19,7 @@ return [
'chunk_bytes' => 2097152, 'chunk_bytes' => 2097152,
'retention_days' => 90, 'retention_days' => 90,
'lease_seconds' => 600, 'lease_seconds' => 600,
'http_transport' => (string) env('followup_audio.HTTP_TRANSPORT', 'curl'),
'request_timeout' => (int) env('followup_audio.REQUEST_TIMEOUT', 240), 'request_timeout' => (int) env('followup_audio.REQUEST_TIMEOUT', 240),
'concurrency' => max(1, min(8, (int) env('followup_audio.CONCURRENCY', 1))), 'concurrency' => max(1, min(8, (int) env('followup_audio.CONCURRENCY', 1))),
// Keep one stable key across web/worker nodes; empty reuses the existing prescription AI key. // Keep one stable key across web/worker nodes; empty reuses the existing prescription AI key.
@@ -46,11 +51,15 @@ return [
// Explicit server-only two-stage identities; no fallback to prescription_ai or another slot. // Explicit server-only two-stage identities; no fallback to prescription_ai or another slot.
'allow_loopback_tunnel' => filter_var(env($prefix . 'ALLOW_LOOPBACK_TUNNEL', false), FILTER_VALIDATE_BOOLEAN), 'allow_loopback_tunnel' => filter_var(env($prefix . 'ALLOW_LOOPBACK_TUNNEL', false), FILTER_VALIDATE_BOOLEAN),
'asr' => [ 'asr' => [
'protocol' => (string) env($prefix . 'ASR_PROTOCOL', 'openai'),
'binding_revision' => (string) env($prefix . 'ASR_BINDING_REVISION', ''),
'base_url' => (string) env($prefix . 'ASR_BASE_URL', ''), 'base_url' => (string) env($prefix . 'ASR_BASE_URL', ''),
'api_key' => (string) env($prefix . 'ASR_API_KEY', ''), 'api_key' => (string) env($prefix . 'ASR_API_KEY', ''),
'model' => (string) env($prefix . 'ASR_MODEL', ''), 'model' => (string) env($prefix . 'ASR_MODEL', ''),
], ],
'extraction' => [ 'extraction' => [
'protocol' => (string) env($prefix . 'EXTRACTION_PROTOCOL', 'openai'),
'binding_revision' => (string) env($prefix . 'EXTRACTION_BINDING_REVISION', ''),
'base_url' => (string) env($prefix . 'EXTRACTION_BASE_URL', ''), 'base_url' => (string) env($prefix . 'EXTRACTION_BASE_URL', ''),
'api_key' => (string) env($prefix . 'EXTRACTION_API_KEY', ''), 'api_key' => (string) env($prefix . 'EXTRACTION_API_KEY', ''),
'model' => (string) env($prefix . 'EXTRACTION_MODEL', ''), 'model' => (string) env($prefix . 'EXTRACTION_MODEL', ''),
@@ -60,6 +69,7 @@ return [
'enable_thinking' => env($prefix . 'EXTRACTION_ENABLE_THINKING', null) === null ? null 'enable_thinking' => env($prefix . 'EXTRACTION_ENABLE_THINKING', null) === null ? null
: (filter_var(env($prefix . 'EXTRACTION_ENABLE_THINKING'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? 'invalid'), : (filter_var(env($prefix . 'EXTRACTION_ENABLE_THINKING'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? 'invalid'),
], ],
'preview_verified_fingerprint' => (string) env($prefix . 'PREVIEW_VERIFIED_FINGERPRINT', ''),
'verified_fingerprint' => (string) env($prefix . 'VERIFIED_FINGERPRINT', ''), 'verified_fingerprint' => (string) env($prefix . 'VERIFIED_FINGERPRINT', ''),
]; ];
}, ['qwen', 'openai'])), }, ['qwen', 'openai'])),
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioPipeline as Pipeline;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioStreamTransport as Stream;
use app\common\service\followupaudio\FollowupAudioException as Error;
$dir = sys_get_temp_dir() . '/fa-dify-stage-' . bin2hex(random_bytes(6)); mkdir($dir, 0700);
new think\App(); $db = new PDO('sqlite:' . $dir . '/dictionary.sqlite');
$db->exec('CREATE TABLE zyt_dict_data(id INTEGER PRIMARY KEY,type_value TEXT,status INTEGER,sort INTEGER,name TEXT,value TEXT)');
$manager = new think\DbManager(); $manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => $dir . '/dictionary.sqlite', 'prefix' => 'zyt_']]]);
think\Container::getInstance()->instance('think\DbManager', $manager);
$checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$reject = static function (callable $call, string $code) use ($expect): void { try { $call(); } catch (Error $e) { $expect($e->errorCode === $code, 'expected ' . $code . ', got ' . $e->errorCode); return; } throw new RuntimeException('Expected ' . $code); };
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); $port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket);
$server = proc_open([PHP_BINARY, '-n', '-S', '127.0.0.1:' . $port, __DIR__ . '/fixtures/followup_audio/dify_pipeline_router.php'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/server.stdout', 'a'], 2 => ['file', $dir . '/server.stderr', 'a']], $pipes, null,
array_merge(getenv(), ['FOLLOWUP_AUDIO_DIFY_MOCK_DIR' => $dir])); fclose($pipes[0]);
$bytes = str_repeat(pack('v', 1000), 16000); $wave = $dir . '/source.wav';
file_put_contents($wave, 'RIFF' . pack('V', 36 + strlen($bytes)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($bytes)) . $bytes); chmod($wave, 0600);
$settings = ['request_timeout' => 5, 'providers' => ['qwen' => ['driver' => 'asr_then_llm', 'label' => 'Synthetic', 'allow_loopback_tunnel' => true,
'asr' => ['protocol' => 'dify', 'binding_revision' => 'synthetic-asr-v1', 'base_url' => 'http://127.0.0.1:' . $port . '/success/v1', 'api_key' => 'synthetic-dify-key', 'model' => 'expected-asr'],
'extraction' => ['protocol' => 'dify_chat', 'binding_revision' => 'synthetic-chat-v1', 'base_url' => 'http://127.0.0.1:' . $port . '/success/v1', 'api_key' => 'synthetic-dify-key', 'model' => 'expected-llm', 'response_format' => 'prompt_json']]]];
$state = []; $fields = [];
$heartbeat = static function (array $value) use (&$state, &$fields): bool { $fields[] = $value; if (isset($value['pipeline_checkpoint'])) { $state = $value['pipeline_checkpoint']['state']; } return true; };
$taskFor = static fn (array $p): array => ['id' => 1, 'model_key' => 'qwen', 'sha256' => hash_file('sha256', $wave), 'duration_seconds' => 1,
'recorded_at' => '2026-09-29 10:00:00', 'upstream_started_at' => 0, 'upstream_ids_json' => json_encode(['provider_fingerprint' => $p['fingerprint']])];
$tls = null;
try {
for ($i = 0; $i < 100; $i++) { $socket = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1); if ($socket) { fclose($socket); break; } usleep(20000); }
foreach (['curl', 'openssl_stream'] as $transport) {
$options = $settings; $options['http_transport'] = $transport; $provider = Provider::resolve('qwen', $options, []); $task = $taskFor($provider); $state = []; $fields = [];
$result = (new Pipeline($options, $provider))->run($wave, $task, $heartbeat);
$expect($result['transcript'] === '今天早晨空腹血糖六点一。' && count($result['items']) === 1, $transport . ' actual Dify stage pipeline');
$expect($state['extraction']['upstream_ids']['message_id'] === 'dify-message-success' && $state['extraction']['upstream_ids']['conversation_id'] === 'dify-conversation-never-reused', 'valid upstream IDs retained');
$rows = array_map(static fn ($line) => json_decode($line, true), file($dir . '/requests.jsonl', FILE_IGNORE_NEW_LINES));
$expect(!in_array(false, array_column($rows, 'valid'), true), 'actual HTTP has Dify-only shape, no local model/generation/conversation leakage');
$before = count($rows); (new Pipeline($options, $provider))->run($wave, $task, $heartbeat, $state);
$expect(count(file($dir . '/requests.jsonl')) === $before, 'known completed Dify results are not resent');
foreach (['reject' => 'ASR_REJECTED', 'unknown' => 'UPSTREAM_UNCERTAIN', 'redirect' => 'UPSTREAM_UNCERTAIN', 'oversize' => 'UPSTREAM_UNCERTAIN'] as $scenario => $code) {
$bad = $options; $bad['providers']['qwen']['asr']['base_url'] = 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1';
if ($scenario === 'oversize') { $bad['max_response_bytes'] = 1024; }
$p = Provider::resolve('qwen', $bad, []); $t = $taskFor($p); $state = []; $fields = [];
$before = count(file($dir . '/requests.jsonl'));
$reject(static fn () => (new Pipeline($bad, $p))->run($wave, $t, $heartbeat), $code);
$expect(count(file($dir . '/requests.jsonl')) === $before + 1, 'no HTTP redirect/protocol/transport fallback on ' . $scenario);
$expect($state['chunks'][0]['state'] === ($scenario === 'reject' ? 'rejected' : 'intent'), 'known rejection vs unresolved intent preserved');
if ($scenario === 'unknown') {
$expect((bool) array_filter($fields, static fn ($value) => isset($value['upstream_run_id']) && $value['upstream_run_id'] === 'unknown-observed-id'), 'unknown response ID retained before error');
$reject(static fn () => (new Pipeline($bad, $p))->run($wave, $t, $heartbeat, $state), 'RECONCILIATION_REQUIRED');
$expect(count(file($dir . '/requests.jsonl')) === $before + 1, 'unknown result is not retried');
}
}
$length = $options; $length['providers']['qwen']['extraction']['base_url'] = 'http://127.0.0.1:' . $port . '/length/v1';
$p = Provider::resolve('qwen', $length, []); $t = $taskFor($p); $state = [];
$reject(static fn () => (new Pipeline($length, $p))->run($wave, $t, $heartbeat), 'UPSTREAM_SCHEMA_INVALID');
$expect($state['extraction']['state'] === 'complete' && $state['extraction']['answer'] === '', 'Dify explicit length finish stays a known completed failure');
}
foreach (['asr', 'extraction'] as $stage) { $bad = $settings; $bad['providers']['qwen'][$stage]['binding_revision'] = ''; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID'); }
$bad = $settings; $bad['providers']['qwen']['extraction']['response_format'] = 'json_schema'; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID');
$bad = $settings; $bad['providers']['qwen']['extraction']['enable_thinking'] = true; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID');
$a = Provider::resolve('qwen', $settings, []); $bad = $settings; $bad['providers']['qwen']['asr']['binding_revision'] = 'synthetic-asr-v2';
$expect(Provider::resolve('qwen', $bad, [])['fingerprint'] !== $a['fingerprint'], 'Dify revision changes identity');
$bad = $settings; $bad['http_transport'] = 'openssl_stream'; $expect(Provider::resolve('qwen', $bad, [])['fingerprint'] !== $a['fingerprint'], 'explicit TLS transport changes identity');
$expect(!isset($a['extraction']['max_tokens'], $a['extraction']['enable_thinking']), 'Dify generation owned by App, not claimed as local effective knobs');
// Verified TLS rejects an untrusted local certificate; no trust store change or verify=false workaround.
$cert = proc_open(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', $dir . '/key.pem', '-out', $dir . '/cert.pem', '-days', '1', '-subj', '/CN=localhost'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/cert.stdout', 'a'], 2 => ['file', $dir . '/cert.stderr', 'a']], $p); fclose($p[0]); $expect(proc_close($cert) === 0, 'local test certificate generated');
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); $tlsPort = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket);
$tls = proc_open(['openssl', 's_server', '-accept', '127.0.0.1:' . $tlsPort, '-cert', $dir . '/cert.pem', '-key', $dir . '/key.pem', '-quiet', '-www'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/tls.stdout', 'a'], 2 => ['file', $dir . '/tls.stderr', 'a']], $p); fclose($p[0]); usleep(200000);
$spec = ['url' => 'https://127.0.0.1:' . $tlsPort . '/v1/chat-messages', 'api_key' => 'synthetic-dify-key', 'timeout' => 3, 'stage' => 'extraction', 'json' => ['inputs' => new stdClass(), 'query' => 'synthetic']];
$reply = Stream::request($spec, static fn (): bool => true, 1024, false);
$expect($reply['errno'] !== 0 && $reply['http_code'] === 0, 'untrusted TLS fails closed');
$spec['url'] = 'http://127.0.0.1:' . $port . '/slow/v1/chat-messages'; $spec['json'] = (new Pipeline($settings, $a))->extractRequest('synthetic', [['id' => 's', 'start_ms' => 0, 'end_ms' => 1000, 'text' => 'synthetic']], '2026-09-29 10:00:00', ['blood' => [['key' => 'systolic_pressure', 'type' => 'number']]]);
$spec['timeout'] = 1; $start = microtime(true); $reply = Stream::request($spec, static fn (): bool => true, 1024, true);
$expect($reply['errno'] === 28 && microtime(true) - $start < 3, 'blocked response headers remain wall-clock bounded');
$spec['timeout'] = 10; $beats = 0; $start = microtime(true);
$reply = Stream::request($spec, static function () use (&$beats): bool { $beats++; return $beats < 2; }, 1024, true);
$expect($reply['errno'] === 42 && $beats === 2 && microtime(true) - $start < 7, 'parent rechecks authorization after5s while child waits for headers');
echo 'FOLLOWUP_AUDIO_DIFY_PIPELINE assertions=' . $checks . ' PASS native_audio_to_text=1 blocking_chat=1 no_fake_generation_args=1 revision_bound=1 curl_and_openssl=1 verified_tls=1 parent_heartbeat=1 body_timeout_limits=1 no_fallback=1 unknown_fenced=1' . PHP_EOL;
} finally {
if (is_resource($tls)) { proc_terminate($tls, 9); proc_close($tls); }
proc_terminate($server, 9); proc_close($server);
foreach (glob($dir . '/*') as $file) { unlink($file); } rmdir($dir);
}
@@ -9,6 +9,8 @@ $access = file_get_contents($root . '/app/common/service/followupaudio/FollowupA
$middleware = file_get_contents($root . '/app/adminapi/http/middleware/AuthMiddleware.php'); $middleware = file_get_contents($root . '/app/adminapi/http/middleware/AuthMiddleware.php');
$stream = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioStream.php'); $stream = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioStream.php');
$console = file_get_contents($root . '/config/console.php'); $console = file_get_contents($root . '/config/console.php');
$apply = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioApply.php');
$gate = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioGate.php');
$checks = 0; $checks = 0;
function expectEndpoint(bool $condition, string $message): void function expectEndpoint(bool $condition, string $message): void
{ {
@@ -26,8 +28,13 @@ expectEndpoint(str_contains($controller, 'array_diff(array_keys($params), $allow
expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review items'); expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review items');
expectEndpoint(str_contains($controller, "'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW'"), 'typed stale review response'); expectEndpoint(str_contains($controller, "'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW'"), 'typed stale review response');
expectEndpoint(substr_count($controller, 'Logic::requireEnabled(true)') >= 3, 'upload capability gate'); expectEndpoint(substr_count($controller, 'Logic::requireEnabled(true)') >= 3, 'upload capability gate');
expectEndpoint(str_contains($logic, "Store::verified(\$p['model_key'])"), 'per-model audio capability gate'); expectEndpoint(str_contains($logic, "Store::ready(\$p['model_key'])"), 'per-model mode-aware readiness gate');
expectEndpoint(str_contains($logic, "'models' => ProviderConfig::publicModels()"), 'only fingerprint-verified server model labels advertised'); expectEndpoint(str_contains($logic, "'models' => \$enabled ? ProviderConfig::readyModels() : []"), 'only scoped fingerprint-ready server labels advertised');
expectEndpoint(str_contains($logic, '$verified = Store::verified()') && str_contains($logic, "'audio_verified' => \$verified"), 'full accuracy verification remains separate');
expectEndpoint(str_contains($logic, "'preview_only' => \$preview") && str_contains($logic, "'can_review' => \$enabled && \$ready"), 'explicit preview and review capabilities');
expectEndpoint(strpos($apply, 'FollowupAudioGate::assertMayApply();') < strpos($apply, '$connection = Db::connect()'), 'preview hard denial before any apply DB access');
expectEndpoint(strpos($apply, 'FollowupAudioGate::assertMayApply($task);') < strpos($apply, "if (\$task['status'] === 'applied')"), 'persistent preview origin denied before idempotent applied path');
expectEndpoint(str_contains($gate, 'FOLLOWUP_AUDIO_PREVIEW_ONLY') && str_contains($gate, 'FOLLOWUP_AUDIO_PREVIEW_SCOPE_DENIED'), 'stable fail-closed preview errors');
expectEndpoint(!str_contains($logic, 'Dify 音频能力') && !str_contains($logic, 'api_key') && !str_contains($logic, 'base_url'), expectEndpoint(!str_contains($logic, 'Dify 音频能力') && !str_contains($logic, 'api_key') && !str_contains($logic, 'base_url'),
'provider-neutral public capabilities never expose credentials or addresses'); 'provider-neutral public capabilities never expose credentials or addresses');
expectEndpoint(str_contains($logic, "(int) \$upload['diagnosis_id'] !== \$p['diagnosis_id']"), 'upload/diagnosis binding'); expectEndpoint(str_contains($logic, "(int) \$upload['diagnosis_id'] !== \$p['diagnosis_id']"), 'upload/diagnosis binding');
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
require __DIR__ . '/fixtures/followup_audio/database.php';
use app\common\service\followupaudio\FollowupAudioGate as Gate;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use app\common\service\followupaudio\FollowupAudioWorker as Worker;
use app\common\service\followupaudio\FollowupAudioDify as Dify;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioPipelineCheckpoint as Checkpoint;
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
use app\adminapi\logic\tcm\FollowupAudioLogic as Logic;
use think\facade\Db;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23319) { throw new RuntimeException('Preview tests require dedicated disposable23319'); }
$provider = ['driver' => 'asr_then_llm', 'label' => 'Synthetic preview',
'asr' => ['protocol' => 'dify', 'binding_revision' => 'test-asr-v1', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'expected-asr'],
'extraction' => ['protocol' => 'dify_chat', 'binding_revision' => 'test-chat-v1', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'expected-llm', 'response_format' => 'prompt_json']];
$f = followupAudioTestDatabase(['preview_only' => true, 'audio_verified' => false, 'verified_profiles' => [], 'test_diagnosis_ids' => '', 'providers' => ['qwen' => $provider]]);
$checks = 0; $calls = ['asr' => 0, 'extraction' => 0]; $active = 0; $mode = 'success';
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$reject = static function (callable $call, string $code) use ($expect): void { try { $call(); } catch (Throwable $e) { $expect(str_contains($e->getMessage(), $code) || ($e->errorCode ?? '') === $code, 'expected ' . $code . ', got ' . $e->getMessage()); return; } throw new RuntimeException('Expected ' . $code); };
$set = static function (array $fields) use ($f): void { $f['config']->set($fields, 'followup_audio'); };
$businessTables = ['zyt_tcm_diagnosis','zyt_tcm_prescription_order','zyt_tcm_blood_record','zyt_patient_diet_record','zyt_patient_exercise_record','zyt_tracking_note','zyt_followup_audio_audit'];
$snapshot = static function () use ($f, $businessTables): string { $rows = []; foreach ($businessTables as $table) { $rows[$table] = $f['pdo']->query('SELECT * FROM `' . $table . '` ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); } return hash('sha256', json_encode($rows)); };
$beforeBusiness = $snapshot();
$connection = Db::connect()->getConfig(); $connection['trigger_sql'] = true;
Db::setConfig(['default' => 'mysql', 'connections' => ['mysql' => $connection]]); Db::connect('mysql', true);
$businessWrites = 0; $featureWrites = 0; $sqlEvents = 0;
Db::listen(static function (string $sql) use (&$businessWrites, &$featureWrites, &$sqlEvents, $businessTables): void {
$sqlEvents++;
if (preg_match('/\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+`?(zyt_[a-z0-9_]+)/i', $sql, $match)) {
if (in_array(strtolower($match[1]), $businessTables, true)) { $businessWrites++; }
elseif (str_starts_with(strtolower($match[1]), 'zyt_followup_audio_')) { $featureWrites++; }
}
});
$make = static function () use ($f, &$active): int {
static $index = 0; $index++; $data = str_repeat(pack('vv', 1000 + $index, 2000 + $index), 16000);
$path = $f['private'] . '/preview-' . $index . '.wav';
file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 2, 16000, 64000, 4, 16) . 'data' . pack('V', strlen($data)) . $data); chmod($path, 0600);
$upload = followupAudioTestUpload($f, $path, 'synthetic.wav');
$task = Logic::create(['diagnosis_id' => 1, 'upload_id' => $upload['id'], 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen'], 1, $f['actor']);
$active = $task['id']; return $active;
};
$transport = static function (array $spec) use (&$active, &$calls, &$mode, $set, $expect): array {
$calls[$spec['stage']]++;
$row = Store::task($active); $state = Store::open($active, 'pipeline', $row['pipeline_cipher']);
$expect(Checkpoint::hasIntent($state) && Gate::taskPreview($row), 'preview origin and intent durable before upstream');
if ($spec['stage'] === 'asr') {
$expect(str_ends_with($spec['url'], '/audio-to-text') && !isset($spec['multipart']['model']) && isset($spec['multipart']['user']), 'actual preview Dify ASR contract');
if ($mode === 'revoke_scope') { $set(['test_diagnosis_ids' => '']); }
if ($mode === 'revoke_admin') { $set(['test_admin_ids' => '2']); }
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['text' => '合成:今天早晨空腹血糖六点一。'], JSON_UNESCAPED_UNICODE)];
}
$expect(str_ends_with($spec['url'], '/chat-messages') && !isset($spec['json']['max_tokens'], $spec['json']['model'], $spec['json']['conversation_id']), 'actual preview Dify extraction contract');
$citation = Prompt::citations(Checkpoint::segments($state))[0];
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成预览', 'uncertainties' => [], 'items' => [[
'kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-29', 'record_time' => '08:00',
'date_text' => '今天', 'time_text' => '早晨', 'time_period' => null, 'time_estimated' => false, 'needs_review' => true, 'evidence_ids' => [$citation['id']],
]]];
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['message_id' => 'preview-dify-message', 'answer' => json_encode($answer, JSON_UNESCAPED_UNICODE)])];
};
$run = static fn (): bool => (new Worker(new Dify($transport)))->runOnce();
try {
foreach (['', 'name-only', '1,,2', '0', '-1', ['1', null], ['id' => 1]] as $ids) {
$set(['test_diagnosis_ids' => $ids]); $cap = Logic::capabilities(1, 1, $f['actor']);
$expect(!$cap['enabled'] && !$cap['test_scope_allowed'] && $cap['models'] === [] && !$cap['can_apply'], 'empty/malformed scope fails closed without breaking capabilities');
$reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED');
}
$set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']);
$expect(!Store::ready() && !Store::verified(), 'neither preview nor full gate fabricated');
$set(['audio_verified' => true, 'verified_profiles' => ['qwen']]);
$expect(Store::verified() && !Store::ready(), 'full gate cannot substitute missing preview fingerprint');
$set(['audio_verified' => false, 'verified_profiles' => []]);
$slots = config('followup_audio.providers'); $slots['qwen']['preview_verified_fingerprint'] = Provider::resolve('qwen')['fingerprint']; $set(['providers' => $slots]);
$cap = Logic::capabilities(1, 1, $f['actor']);
$expect($cap['enabled'] && $cap['preview_only'] && $cap['preview_ready'] && $cap['test_scope_allowed'] && $cap['can_upload'] && $cap['can_review'] && !$cap['can_apply'] && !$cap['audio_verified'], 'preview upload/review separately ready, clinical apply always false');
$outside = Logic::capabilities(2, 1, $f['actor']);
$expect(!$outside['enabled'] && !$outside['test_scope_allowed'] && $outside['models'] === [] && $outside['fields'] === [], 'other diagnosis normal editor receives disabled audio capabilities');
$reject(fn () => Upload::createSession(2, 'synthetic.wav', 44, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED');
$reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 2, []), 'PREVIEW_SCOPE_DENIED');
$set(['test_admin_ids' => '']);
$reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 3, ['root' => 1]), '诊单不存在或无权操作');
$set(['test_admin_ids' => '1']);
$id = $make(); $claim = Store::claim();
$reject(fn () => Store::checkpoint($id, $claim['lease_token'], ['upstream_ids_json' => ['preview_only' => false]]), 'CHECKPOINT_INVALID');
$expect(Gate::taskPreview(Store::task($id)), 'preview-origin snapshot cannot be cleared by callback');
Store::fail($id, $claim['lease_token'], 'SYNTHETIC_LOCAL_CHECK', ''); Store::retry($id);
$expect($run(), 'actual Worker processes ready preview while full verified false');
$detail = Logic::detail($id, 1, $f['actor']);
$expect($detail['status'] === 'review' && $detail['preview_only'] && $detail['channel_roles'] === 'unconfirmed' && $calls === ['asr' => 2, 'extraction' => 1], 'allowed preview reaches transcript/review only');
$items = $detail['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false;
$saved = Logic::saveDraft($id, $detail['version'], $items, 1, $f['actor'], 'left_service');
$expect($saved['channel_roles'] === 'left_service' && !$saved['items'][0]['selected'], 'preview role and draft remain usable');
$items = $saved['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false;
$saved = Logic::saveDraft($id, $saved['version'], $items, 1, $f['actor']);
$reject(fn () => Logic::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$set(['audio_verified' => true, 'verified_profiles' => ['qwen']]);
$reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$set(['preview_only' => false]);
$expect(Store::verified() && Store::detail($id)['preview_only'], 'origin survives later normal/full-verified configuration');
$reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
Db::name('followup_audio_task')->where('id', $id)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => Store::seal($id, 'applied', ['items' => []])]);
$reject(fn () => Apply::apply($id, $saved['version'], [], 1, $f['actor']), 'PREVIEW_ONLY');
$set(['preview_only' => true, 'audio_verified' => false, 'verified_profiles' => []]);
$reject(fn () => Apply::apply($id, 999, [], 1, $f['actor']), 'PREVIEW_ONLY');
foreach (['revoke_scope', 'revoke_admin'] as $mode) {
$set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']); $unknownId = $make(); $beforeCalls = $calls; $run();
$row = Store::task($unknownId);
$expect($row['status'] === 'needs_reconciliation' && $calls['asr'] === $beforeCalls['asr'] + 1 && $calls['extraction'] === $beforeCalls['extraction'], 'scope/admin revoked after first request stops remaining upstream work');
$state = Store::open($unknownId, 'pipeline', $row['pipeline_cipher']); $expect(Checkpoint::hasIntent($state), 'unknown in-flight outcome retained');
$reject(fn () => Logic::detail($unknownId, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED');
$set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']);
$reject(fn () => Store::retry($unknownId), 'RETRY_NOT_SAFE');
$expect(Store::task($unknownId)['pipeline_cipher'] === $row['pipeline_cipher'], 'restoring allowlist does not clear unknown checkpoint');
}
$mode = 'success'; $set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']); $queued = $make(); $set(['test_diagnosis_ids' => '']); $beforeCalls = $calls;
$expect(!$run() && Store::task($queued)['status'] === 'queued' && $calls === $beforeCalls, 'revoked queued diagnosis not claimed or sent');
$expect($snapshot() === $beforeBusiness && $businessWrites === 0 && $featureWrites > 0 && $sqlEvents > 0, 'actual SQL listener: feature writes allowed, all clinical/audit tables zero writes and unchanged');
echo 'FOLLOWUP_AUDIO_PREVIEW assertions=' . $checks . ' PASS mysql23319=1 full_audio_verified_not_faked=1 allowlist_fail_closed=1 rbac=1 preview_review=1 clinical_writes=0 apply_root_direct_applied_denied=1 origin_permanent=1 revoke_stops_upstream=1 unknown_preserved=1' . PHP_EOL;
} finally { followupAudioTestDatabaseCleanup($f); }
@@ -27,7 +27,7 @@ namespace {
return $value; return $value;
} }
$root = dirname(__DIR__) . '/app/common/service/followupaudio/'; $root = dirname(__DIR__) . '/app/common/service/followupaudio/';
foreach (['Exception', 'ProviderConfig', 'Store'] as $class) { require $root . 'FollowupAudio' . $class . '.php'; } foreach (['Exception', 'ProviderConfig', 'Gate', 'Store'] as $class) { require $root . 'FollowupAudio' . $class . '.php'; }
require dirname(__DIR__) . '/app/adminapi/logic/tcm/FollowupAudioLogic.php'; require dirname(__DIR__) . '/app/adminapi/logic/tcm/FollowupAudioLogic.php';
use app\common\service\followupaudio\FollowupAudioStore as Store; use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Providers; use app\common\service\followupaudio\FollowupAudioProviderConfig as Providers;
@@ -11,7 +11,7 @@ use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy; use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use think\facade\Db; use think\facade\Db;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23317) { throw new RuntimeException('Stereo test requires its separate disposable23317'); } if (!in_array((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT'), [23317, 23319], true)) { throw new RuntimeException('Stereo test requires a dedicated disposable test port'); }
$f = followupAudioTestDatabase(['asr_stereo_chunk_seconds' => 2]); $f = followupAudioTestDatabase(['asr_stereo_chunk_seconds' => 2]);
$checks = 0; $calls = ['asr' => 0, 'extraction' => 0]; $active = 0; $mode = 'success'; $checks = 0; $calls = ['asr' => 0, 'extraction' => 0]; $active = 0; $mode = 'success';
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; }; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
@@ -117,5 +117,5 @@ try {
$legacyAppliedId = $make(1); $oldAppliedCipher = Store::seal($legacyAppliedId, 'applied', ['items' => [['kind' => 'blood', 'record_id' => 999]]]); $legacyAppliedId = $make(1); $oldAppliedCipher = Store::seal($legacyAppliedId, 'applied', ['items' => [['kind' => 'blood', 'record_id' => 999]]]);
Db::name('followup_audio_task')->where('id', $legacyAppliedId)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => $oldAppliedCipher]); Db::name('followup_audio_task')->where('id', $legacyAppliedId)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => $oldAppliedCipher]);
$expect(Store::detail($legacyAppliedId)['applied_items'] === [['kind' => 'blood', 'record_id' => 999]] && Store::task($legacyAppliedId)['applied_cipher'] === $oldAppliedCipher, 'old applied results unchanged'); $expect(Store::detail($legacyAppliedId)['applied_items'] === [['kind' => 'blood', 'record_id' => 999]] && Store::task($legacyAppliedId)['applied_cipher'] === $oldAppliedCipher, 'old applied results unchanged');
echo 'FOLLOWUP_AUDIO_STEREO_DATABASE assertions=' . $checks . ' PASS mysql23317=1 actual_worker=1 both_channels=1 role_scope_version=1 role_change_clears=1 apply_role_required=1 immutable_channels=1 audit_annotation=1 unknown_fenced=1 local_silence=1 legacy_unchanged=1' . PHP_EOL; echo 'FOLLOWUP_AUDIO_STEREO_DATABASE assertions=' . $checks . ' PASS mysql' . (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') . '=1 actual_worker=1 both_channels=1 role_scope_version=1 role_change_clears=1 apply_role_required=1 immutable_channels=1 audit_annotation=1 unknown_fenced=1 local_silence=1 legacy_unchanged=1' . PHP_EOL;
} finally { followupAudioTestDatabaseCleanup($f); } } finally { followupAudioTestDatabaseCleanup($f); }
+1
View File
@@ -17,6 +17,7 @@ namespace app\common\service\followupaudio {
public static bool $providerMatches = true; public static bool $providerMatches = true;
public static function enabled(): bool { return self::$enabled; } public static function enabled(): bool { return self::$enabled; }
public static function verified(?string $profile = null): bool { return self::$verified; } public static function verified(?string $profile = null): bool { return self::$verified; }
public static function ready(?string $profile = null): bool { return self::$verified; }
public static function claim(): ?array { self::$events[] = 'claim'; return self::$verified ? ['id' => 1, 'actor_id' => 1, 'diagnosis_id' => 1, 'lease_token' => 'test', 'model_key' => 'qwen'] : null; } public static function claim(): ?array { self::$events[] = 'claim'; return self::$verified ? ['id' => 1, 'actor_id' => 1, 'diagnosis_id' => 1, 'lease_token' => 'test', 'model_key' => 'qwen'] : null; }
public static function assertTaskProvider(array $task): void { if (!self::$providerMatches) { throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED'); } } public static function assertTaskProvider(array $task): void { if (!self::$providerMatches) { throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED'); } }
public static function heartbeat(int $id, string $token): bool { self::$events[] = 'heartbeat'; return self::$lease; } public static function heartbeat(int $id, string $token): bool { self::$events[] = 'heartbeat'; return self::$lease; }
@@ -0,0 +1,34 @@
<?php
$directory = getenv('FOLLOWUP_AUDIO_DIFY_MOCK_DIR');
$uri = $_SERVER['REQUEST_URI'];
$audio = str_ends_with($uri, '/audio-to-text'); $chat = str_ends_with($uri, '/chat-messages');
$valid = ($_SERVER['HTTP_AUTHORIZATION'] ?? '') === 'Bearer synthetic-dify-key';
if ($audio) {
$valid = $valid && array_keys($_POST) === ['user'] && preg_match('/^fa-opaque-[a-f0-9]{48}$/D', $_POST['user'] ?? '')
&& isset($_FILES['file']) && ($_FILES['file']['type'] ?? '') === 'audio/wav' && is_file($_FILES['file']['tmp_name']);
$meta = ['stage' => 'asr', 'valid' => (bool) $valid, 'uri' => $uri, 'bytes' => isset($_FILES['file']) ? filesize($_FILES['file']['tmp_name']) : 0];
} elseif ($chat) {
$raw = file_get_contents('php://input'); $object = json_decode($raw); $body = json_decode($raw, true);
$valid = $valid && is_object($object->inputs ?? null) && (array) $object->inputs === [] && is_string($body['query'] ?? null)
&& ($body['response_mode'] ?? '') === 'blocking' && ($body['auto_generate_name'] ?? null) === false
&& preg_match('/^fa-opaque-[a-f0-9]{48}$/D', $body['user'] ?? '')
&& array_diff(array_keys($body), ['inputs', 'query', 'response_mode', 'user', 'auto_generate_name']) === [];
$meta = ['stage' => 'extraction', 'valid' => (bool) $valid, 'uri' => $uri];
} else { http_response_code(404); echo '{}'; return; }
file_put_contents($directory . '/requests.jsonl', json_encode($meta) . "\n", FILE_APPEND);
header('Content-Type: application/json');
if (!$valid) { http_response_code(400); echo '{"code":"bad_contract"}'; return; }
if (str_contains($uri, '/slow/')) { sleep(12); }
if (str_contains($uri, '/redirect/')) { http_response_code(302); header('Location: /success/v1/audio-to-text'); echo '{}'; return; }
if (str_contains($uri, '/reject/')) { http_response_code(429); echo '{"message_id":"known-reject-id"}'; return; }
if (str_contains($uri, '/unknown/')) { http_response_code(500); echo '{"message_id":"unknown-observed-id"}'; return; }
if (str_contains($uri, '/oversize/')) { echo json_encode(['text' => str_repeat('x', 200000)]); return; }
if ($audio) { echo json_encode(['text' => '今天早晨空腹血糖六点一。'], JSON_UNESCAPED_UNICODE); return; }
preg_match('/c_[a-f0-9]{16,64}/', $body['query'], $match);
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成摘要', 'uncertainties' => [], 'items' => [[
'kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-29', 'record_time' => null,
'date_text' => '今天', 'time_text' => '早晨', 'time_period' => '早晨', 'time_estimated' => true, 'needs_review' => true,
'evidence_ids' => [$match[0]],
]]];
echo json_encode(['event' => 'message', 'message_id' => 'dify-message-success', 'task_id' => 'dify-task-success', 'conversation_id' => 'dify-conversation-never-reused',
'answer' => json_encode($answer, JSON_UNESCAPED_UNICODE), 'metadata' => ['finish_reason' => str_contains($uri, '/length/') ? 'length' : 'stop']]);