feat: add scoped read-only audio preview and isolated Dify transport
This commit is contained in:
@@ -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 '请选择支持的录音格式'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
Executable
+36
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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共用现有编辑组件;桌面客户端不增加入口。
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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']]);
|
||||||
Reference in New Issue
Block a user