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

This commit is contained in:
2026-10-09 16:10:59 +08:00
parent 27772bec61
commit 4d0af7f3f4
36 changed files with 1517 additions and 68 deletions
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.env
*.private.*
asr-key
+34
View File
@@ -0,0 +1,34 @@
# Isolated Dify ASR deployment — 2026-10-09
This directory adds only a CPU-only fixed-upstream bridge, a new speech2text model/default, and an independent Dify chat App. Do not apply its compose file to the old Dify or ASR project. The two networks are external and must not be deleted.
- AI host deployment root: `/home/www/qwen-vllm/dify-integration-20261009`
- Public API base: `https://ai.zhenyangtang.com.cn/v1`
- New App: `ea0d7293-3d89-425d-a236-015fac7e2470`
- App mode chat; public site disabled; API enabled; max active chat requests 1.
- ASR: `Qwen/Qwen3-ASR-1.7B`; extraction: existing `qwen3.6-35b`.
- App max_tokens 8192, temperature 0, enable_thinking false; JSON requested in the system/query prompts, not native JSON-schema enforcement. Existing Qwen model has strict compatibility/not_supported thinking; SDK may filter the explicit false parameter. The existing Qwen server's `--default-chat-template-kwargs {"enable_thinking":false}` is the effective server control, verified without changing it.
- Binding revision: `followup-dify-20261009-v1-99ebe159aef4dce6`. Any App/provider/default/model-serving/bridge change requires a new revision and new acceptance. This is a version binding, not an automatic remote drift monitor.
- Bridge: existing pinned Python 3.12 image, two Docker networks, no host port, no GPU, UID1000, read-only root, all capabilities dropped, no-new-privileges, 256MiB/0.5CPU/64PIDs; in-flight 1, token bucket 120/min burst4, 30MiB+64KiB multipart limit, 180s fixed-upstream timeout, no redirects or POST retry.
- Secrets exist only in private 0600 host files and encrypted Dify credentials. Never print `resources.private.json`, `asr-key`, `dify-runtime.env`, or `operator-error.private.log`.
- Provisioning uses deployed `ModelProviderService`, `AppService`, model-config and key controllers, including provider validation/encryption. It never handcrafts encrypted provider rows. Provision is single-use; inspect exact state after any failure before resuming.
## Validation
Run `python3 test_bridge.py` locally for eight synthetic mock-server tests. `public_probe.py PRIVATE_ENV SYNTHETIC_WAV [all|asr|chat] [EXPECTED_CANARIES_JSON]` always verifies HTTPS certificates/hostname, never emits keys, and sends canary strings only in audio, not in ASR form prompts. Optional expected canaries are verifier inputs only. Preserve failures; passing connection tests do not establish full transcription accuracy.
Initial ASR fixture reproduced a homophone error: `红色石榴` became `红色石流`; its strict four-canary result remains failed even though the other three unique canaries match and HTTPS ASR returns 200. This release is for synthetic/preview-only validation, not clinical writeback or an assertion of complete accuracy.
Webhost Python/OpenSSL validates this TLS endpoint. Webhost legacy PHP/cURL NSS certificate-key-usage behavior requires independent acceptance; do not disable TLS verification or infer PHP success from Python success.
## Exact rollback
On AI host, execute only this release's `/home/www/qwen-vllm/dify-integration-20261009/ROLLBACK.sh`:
- `--quiesce`: disable only the new App API and stop only its recorded bridge container; verified live.
- `--restore`: start the same new bridge, check health, re-enable only the new App API; verified live.
- `--remove-new-objects`: revoke the precise new key, delete the precise new App, remove this newly introduced non-secret speech2text default and credential through guarded ORM/service operations, and remove only this bridge. This final removal is packaged, not exercised against the live desired deployment. It aborts if another App now uses speech2text or resource IDs differ.
No command deletes shared networks, old providers/models/apps, model files, or old containers. Apply rollback before removing the private resource manifest; preserve it for identity checks. No old source files or global configuration are edited.
After live quiesce/restore, a separate 13.03075s synthetic fixture (`蓝色风筝/绿色森林/白色帆船/黑色雨伞`) passed all four audio-only canaries through the Webhost's fully verified HTTPS Python/OpenSSL route (ASR 200, 1.284s); the JSON chat canary also passed (200, 0.539s). This additional successful connection case does not overwrite the retained first fixture's homophone failure.
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# Only this release. Default reversible quiesce; --remove-new-objects is permanent.
set -eu
BASE=/home/www/qwen-vllm/dify-integration-20261009
ID=7c3ca77d77cac063d6b98310e59d2a73b896a0f5171ba002d41eccab1c067a59
MODE=${1:---quiesce}
case "$MODE" in --quiesce|--restore|--remove-new-objects) ;; *) echo 'usage: ROLLBACK.sh [--quiesce|--restore|--remove-new-objects]' >&2; exit 2;; esac
python3 - "$BASE" <<'PY'
import json,pathlib,sys
r=json.loads((pathlib.Path(sys.argv[1])/'resources.private.json').read_text())
expected={'app_id':'ea0d7293-3d89-425d-a236-015fac7e2470','key_id':'ecfb7628-1369-41fa-b132-d24074af8470','asr_model_id':'e55a7374-a43e-4b86-9323-005a2a72458b','asr_credential_id':'01a11f7e-a148-7b9c-82c7-ffc1425b40ee','asr_default_id':'56ca4093-61fa-446b-ad77-b10deb9daaa4'}
assert all(r.get(k)==v for k,v in expected.items()),'RESOURCE_IDENTITY_MISMATCH'
print('EXACT_NEW_RESOURCES_VERIFIED')
PY
ACTUAL=$(docker inspect --format '{{.Id}}' followup-asr-bridge-20261009)
[ "$ACTUAL" = "$ID" ] || { echo 'BRIDGE_IDENTITY_MISMATCH' >&2; exit 3; }
[ "$(docker inspect --format '{{index .Config.Labels "com.zyt.release"}}' "$ID")" = followup-dify-20261009 ] || exit 4
if [ "$MODE" = --restore ]; then
docker start "$ID"
for n in 1 2 3 4 5 6 7 8 9 10; do
if docker exec "$ID" python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health',timeout=4).read()"; then break; fi
sleep 1
done
docker exec "$ID" python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health',timeout=4).read()"
python3 "$BASE/host_operator.py" enable
echo RESTORED_NEW_ASR_APP_AND_BRIDGE
elif [ "$MODE" = --quiesce ]; then
python3 "$BASE/host_operator.py" disable
docker stop --time 10 "$ID"
echo NEW_ASR_APP_AND_BRIDGE_QUIESCED
else
python3 "$BASE/host_operator.py" remove
docker stop --time 10 "$ID"
docker rm "$ID"
echo NEW_APP_MODEL_DEFAULT_BRIDGE_REMOVED_SHARED_RESOURCES_PRESERVED
fi
+165
View File
@@ -0,0 +1,165 @@
"""Fixed-upstream ASR gateway: stdlib only, no retry, no body/header logging."""
from __future__ import annotations
import hmac
import http.client
import json
import os
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
MAX_BODY = 30 * 1024 * 1024 + 65536 # Dify accepts 30 MiB file; multipart allowance.
MAX_RESPONSE = 4 * 1024 * 1024
UPSTREAM_HOST = 'followup-audio-asr'
UPSTREAM_PORT = 8000
RATE_PER_MINUTE = 120
RATE_BURST = 4
class Gate:
def __init__(self, rate=RATE_PER_MINUTE, burst=RATE_BURST):
self.rate, self.burst = rate, burst
self.tokens, self.at = float(burst), time.monotonic()
self.lock = threading.Lock()
self.active = threading.BoundedSemaphore(1)
def take(self):
with self.lock:
now = time.monotonic()
self.tokens = min(float(self.burst), self.tokens + (now - self.at) * self.rate / 60)
self.at = now
if self.tokens < 1:
return False
self.tokens -= 1
return True
class Server(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
request_queue_size = 8
def __init__(self, address, handler, *, key, upstream=(UPSTREAM_HOST, UPSTREAM_PORT), rate=RATE_PER_MINUTE, burst=RATE_BURST, timeout=180):
super().__init__(address, handler)
self.key, self.upstream, self.timeout = key, upstream, timeout
self.gate = Gate(rate, burst)
self.connections = threading.BoundedSemaphore(8)
def process_request(self, request, client_address):
if not self.connections.acquire(blocking=False):
try:
request.sendall(b'HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
finally:
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except BaseException:
self.connections.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.connections.release()
def handle_error(self, request, client_address):
# Deliberately suppress tracebacks/request fragments from client errors.
print(json.dumps({'event': 'client_error'}), flush=True)
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
server_version = 'FollowupASRBridge/1'
sys_version = ''
def setup(self):
super().setup()
self.connection.settimeout(30)
def log_message(self, format, *args):
pass
def reply(self, code, body, content_type='application/json'):
self.close_connection = True
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.send_header('Connection', 'close')
self.send_header('Cache-Control', 'no-store')
if code == 429:
self.send_header('Retry-After', '1')
self.end_headers()
self.wfile.write(body)
def error_json(self, code, label):
self.reply(code, json.dumps({'error': label}).encode())
def authorized(self):
supplied = self.headers.get('Authorization', '')
return hmac.compare_digest(supplied.encode(), b'Bearer ' + self.server.key)
def upstream_call(self, method, path, body=None, content_type=None, health=False):
conn = http.client.HTTPConnection(*self.server.upstream, timeout=3 if health else self.server.timeout)
try:
headers = {} if health else {'Authorization': 'Bearer ' + self.server.key.decode()}
if content_type:
headers['Content-Type'] = content_type
conn.request(method, path, body=body, headers=headers)
response = conn.getresponse()
result = response.read(MAX_RESPONSE + 1)
if len(result) > MAX_RESPONSE:
return self.error_json(502, 'upstream_response_too_large')
if health:
return self.reply(200 if response.status == 200 else 503, b'{"status":"ok"}' if response.status == 200 else b'{"status":"unhealthy"}')
self.reply(response.status, result, response.getheader('Content-Type', 'application/json'))
except (OSError, http.client.HTTPException, TimeoutError):
self.error_json(503 if health else 502, 'upstream_unavailable')
finally:
conn.close()
def do_GET(self):
if self.path == '/health':
return self.upstream_call('GET', '/health', health=True)
if self.path != '/v1/models':
return self.error_json(404, 'not_found')
if not self.authorized():
return self.error_json(401, 'unauthorized')
return self.upstream_call('GET', '/v1/models')
def do_POST(self):
if self.path != '/v1/audio/transcriptions':
return self.error_json(404, 'not_found')
if not self.authorized():
return self.error_json(401, 'unauthorized')
if self.headers.get('Transfer-Encoding') or not self.headers.get('Content-Length', '').isdigit():
return self.error_json(411, 'content_length_required')
length = int(self.headers['Content-Length'])
if length > MAX_BODY:
return self.error_json(413, 'body_too_large')
if length <= 0:
return self.error_json(400, 'empty_body')
content_type = self.headers.get('Content-Type', '')
if not content_type.startswith('multipart/form-data;') or 'boundary=' not in content_type:
return self.error_json(415, 'multipart_required')
if not self.server.gate.active.acquire(blocking=False):
return self.error_json(429, 'asr_busy')
try:
if not self.server.gate.take():
return self.error_json(429, 'rate_limit')
try:
body = self.rfile.read(length)
except (OSError, socket.timeout):
return self.error_json(408, 'upload_timeout')
if len(body) != length:
return self.error_json(400, 'incomplete_body')
return self.upstream_call('POST', '/v1/audio/transcriptions', body, content_type)
finally:
self.server.gate.active.release()
if __name__ == '__main__':
key = Path('/run/secrets/asr-key').read_bytes().strip()
if len(key) < 16 or b'\n' in key or b'\r' in key:
raise SystemExit('invalid_secret_file')
print(json.dumps({'event': 'started', 'rate_per_minute': RATE_PER_MINUTE, 'inflight': 1, 'host_ports': False}), flush=True)
Server(('0.0.0.0', 8080), Handler, key=key).serve_forever()
@@ -0,0 +1,41 @@
services:
bridge:
image: sha256:34386ef0cb081344d7ec1c103ba398e6e9f64e9ab3a1509accc92a4e24a07258
container_name: followup-asr-bridge-20261009
user: '1000:1000'
entrypoint: ['python', '-B', '/app/bridge.py']
working_dir: /app
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: ['no-new-privileges:true']
pids_limit: 64
cpus: 0.50
mem_limit: 256m
memswap_limit: 256m
tmpfs: ['/tmp:size=16m,noexec,nosuid,nodev']
volumes:
- './bridge.py:/app/bridge.py:ro'
- './asr-key:/run/secrets/asr-key:ro'
networks:
dify:
aliases: [followup-asr-bridge]
asr: {}
healthcheck:
test: ['CMD', 'python', '-c', "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health',timeout=4).read()"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: json-file
options: {max-size: 1m, max-file: '2'}
labels:
com.zyt.release: followup-dify-20261009
networks:
dify:
external: true
name: dify_v1110_eera_default
asr:
external: true
name: followup-audio-asr_default
@@ -0,0 +1,119 @@
"""Execute in Dify API Python with PAYLOAD supplied by private host wrapper.
Uses deployed services/controllers for credential encryption and app config.
Never print this process output to public logs: create returns a new app key to
its private caller; the wrapper writes it 0600 and emits only public metadata.
"""
import hashlib
import inspect
import json
import logging
import sys
logging.disable(logging.CRITICAL)
from app_factory import create_app
app = create_app()
from sqlalchemy import select
from extensions.ext_database import db
from models.account import Account, Tenant, TenantAccountJoin
from models.model import App, ApiToken
from models.provider import ProviderModel, ProviderModelCredential, TenantDefaultModel
from services.app_service import AppService
from services.model_provider_service import ModelProviderService
PROVIDER = 'langgenius/openai_api_compatible/openai_api_compatible'
ASR = 'Qwen/Qwen3-ASR-1.7B'
APP_NAME = 'followup-audio-isolated-20261009'
ENDPOINT = 'http://followup-asr-bridge:8080/v1'
CONFIG = {
'model': {'provider': PROVIDER, 'name': 'qwen3.6-35b', 'mode': 'chat',
'completion_params': {'max_tokens': 8192, 'temperature': 0.0, 'top_p': 1.0, 'enable_thinking': False}},
'pre_prompt': '仅依据本次用户请求处理提供的文本。遵循用户给定的 JSON 格式,只返回最终 JSON,不输出思考过程、解释或 Markdown。不得把示例当成事实。',
'prompt_type': 'simple', 'user_input_form': [],
'speech_to_text': {'enabled': True}, 'text_to_speech': {'enabled': False},
'suggested_questions_after_answer': {'enabled': False},
'retriever_resource': {'enabled': False}, 'sensitive_word_avoidance': {'enabled': False},
'file_upload': {'enabled': False},
}
with app.app_context():
# Anchor only to the existing default Qwen workspace. Fail if ambiguous.
tenant_ids = db.session.scalars(select(TenantDefaultModel.tenant_id).where(
TenantDefaultModel.model_type == 'text-generation', TenantDefaultModel.model_name == 'qwen3.6-35b')).all()
assert len(tenant_ids) == 1, 'AMBIGUOUS_WORKSPACE'
tenant_id = tenant_ids[0]
tenant = db.session.get(Tenant, tenant_id)
owner = db.session.scalars(select(Account).join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id).where(
TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == 'owner')).one()
owner.current_tenant = tenant
def current_app_owned():
resource = PAYLOAD['resources']
obj = db.session.get(App, resource['app_id'])
assert obj and obj.name == APP_NAME and obj.tenant_id == tenant_id, 'APP_IDENTITY_MISMATCH'
return obj
def public_state(obj):
model = db.session.scalars(select(ProviderModel).where(ProviderModel.tenant_id == tenant_id,
ProviderModel.provider_name == PROVIDER, ProviderModel.model_name == ASR, ProviderModel.model_type == 'speech2text')).one()
default = db.session.scalars(select(TenantDefaultModel).where(TenantDefaultModel.tenant_id == tenant_id,
TenantDefaultModel.model_type == 'speech2text')).one()
config = obj.app_model_config.to_dict()
return {'app_id':obj.id,'app_name':obj.name,'tenant_id':tenant_id,'app_mode':obj.mode,
'enable_api':obj.enable_api,'enable_site':obj.enable_site,'config_id':obj.app_model_config_id,
'speech_to_text':obj.app_model_config.speech_to_text_dict,
'model':obj.app_model_config.model_dict,'pre_prompt_sha256':hashlib.sha256(obj.app_model_config.pre_prompt.encode()).hexdigest(),
'config_sha256':hashlib.sha256(json.dumps(config,ensure_ascii=False,sort_keys=True,default=str).encode()).hexdigest(),
'asr_model_id':model.id,'asr_credential_id':model.credential_id,'asr_default_id':default.id,
'asr_model':model.model_name,'asr_default':default.model_name,'provider':PROVIDER,'endpoint':ENDPOINT,
'base_url':'https://ai.zhenyangtang.com.cn/v1','max_active_requests':obj.max_active_requests}
result = {}
with app.test_request_context('/', method='POST', json=CONFIG):
app.login_manager._update_request_context_with_user(owner)
svc = AppService()
action = PAYLOAD['action']
if action == 'create':
assert not db.session.scalar(select(App.id).where(App.name == APP_NAME)), 'APP_NAME_ALREADY_EXISTS'
assert not db.session.scalar(select(ProviderModel.id).where(ProviderModel.tenant_id == tenant_id,ProviderModel.model_type == 'speech2text')), 'SPEECH2TEXT_NOT_EMPTY'
assert not db.session.scalar(select(TenantDefaultModel.id).where(TenantDefaultModel.tenant_id == tenant_id,TenantDefaultModel.model_type == 'speech2text')), 'DEFAULT_NOT_EMPTY'
ModelProviderService().create_model_credential(tenant_id,PROVIDER,'speech2text',ASR,
{'api_key':PAYLOAD['asr_key'],'endpoint_url':ENDPOINT,'endpoint_model_name':ASR,'language':'zh','initial_prompt':'','display_name':'Followup ASR isolated'},
'followup-asr-isolated-20261009')
ModelProviderService().update_default_model_of_model_type(tenant_id,'speech2text',PROVIDER,ASR)
obj=svc.create_app(tenant_id,{'mode':'chat','name':APP_NAME,'description':'Isolated synthetic-verified ASR and JSON extraction. Preview only; no clinical writes.','icon_type':'emoji','icon':'🎙️','icon_background':'#E4FBCC'},owner)
svc.update_app_site_status(obj,False)
from controllers.console.app.model_config import ModelConfigResource
inspect.unwrap(ModelConfigResource.post)(ModelConfigResource(),obj)
svc.update_app(obj, {'name':APP_NAME,'description':obj.description,'icon_type':obj.icon_type,'icon':obj.icon,'icon_background':obj.icon_background,'use_icon_as_answer_icon':False,'max_active_requests':1})
svc.update_app_api_status(obj,True)
from controllers.console.apikey import AppApiKeyListResource, BaseApiKeyListResource
token,status=inspect.unwrap(BaseApiKeyListResource.post)(AppApiKeyListResource(),obj.id)
assert status == 201
result={'public':public_state(obj),'private':{'api_key':token.token,'key_id':token.id}}
elif action in ('status','disable','enable'):
obj=current_app_owned()
if action != 'status':svc.update_app_api_status(obj,action == 'enable')
result={'public':public_state(obj)}
elif action == 'remove':
resource=PAYLOAD['resources'];obj=current_app_owned()
credential=db.session.get(ProviderModelCredential,resource['asr_credential_id'])
recorded_default=db.session.get(TenantDefaultModel,resource['asr_default_id'])
assert credential and credential.tenant_id==tenant_id and credential.model_name==ASR and credential.model_type=='speech2text', 'CREDENTIAL_IDENTITY_MISMATCH'
assert recorded_default and recorded_default.tenant_id==tenant_id and recorded_default.model_name==ASR and recorded_default.model_type=='speech2text', 'DEFAULT_IDENTITY_MISMATCH'
for other in db.session.scalars(select(App).where(App.tenant_id==tenant_id,App.id!=obj.id)).all():
features=(other.workflow.features_dict if other.workflow else {}) if other.mode in ('advanced-chat','workflow') else ({'speech_to_text':other.app_model_config.speech_to_text_dict} if other.app_model_config else {})
assert not features.get('speech_to_text',{}).get('enabled'), 'ANOTHER_APP_NOW_USES_ASR'
svc.update_app_api_status(obj,False)
# Only the precise new app key, via the deployed API controller.
from controllers.console.apikey import AppApiKeyResource, BaseApiKeyResource
key_id=resource['key_id']
if db.session.get(ApiToken,key_id):
inspect.unwrap(BaseApiKeyResource.delete)(AppApiKeyResource(),obj.id,key_id)
svc.delete_app(obj)
default=db.session.get(TenantDefaultModel,resource['asr_default_id'])
assert default and default.tenant_id==tenant_id and default.model_type=='speech2text' and default.model_name==ASR, 'DEFAULT_IDENTITY_MISMATCH'
# No service offers reset-to-empty; remove only this recorded, new, non-secret default row.
db.session.delete(default);db.session.commit()
ModelProviderService().remove_model_credential(tenant_id,PROVIDER,'speech2text',ASR,resource['asr_credential_id'])
assert not db.session.scalar(select(App.id).where(App.id==resource['app_id']))
assert not db.session.scalar(select(ProviderModel.id).where(ProviderModel.id==resource['asr_model_id']))
result={'public':{'removed_app_id':resource['app_id'],'speech2text_restored_empty':True}}
else:raise ValueError('UNKNOWN_ACTION')
print('OPERATOR_RESULT='+json.dumps(result,ensure_ascii=False,default=str))
@@ -0,0 +1,27 @@
"""Host-private Dify service invocation, emits no credentials."""
import json,os,pathlib,subprocess,sys
BASE=pathlib.Path(__file__).resolve().parent
os.umask(0o077)
action=sys.argv[1]
payload={'action':action}
resources=BASE/'resources.private.json'
if action=='create':
payload['asr_key']=(BASE/'asr-key').read_text().strip()
assert not resources.exists(), 'RESOURCE_FILE_ALREADY_EXISTS'
else:payload['resources']=json.loads(resources.read_text())
code='PAYLOAD='+repr(payload)+'\n'+(BASE/'dify_operator.py').read_text()
p=subprocess.run(['docker','exec','-i','dify_v1110_eera-api-1','python','-'],input=code,text=True,capture_output=True)
# Never print raw stdout/stderr: credentials may exist in structured output/errors.
lines=[s for s in p.stdout.splitlines() if s.startswith('OPERATOR_RESULT=')]
if p.returncode or len(lines)!=1:
# Private diagnostic file; callers must not copy it into public artifacts.
(BASE/'operator-error.private.log').write_text(p.stdout+'\n'+p.stderr)
print(json.dumps({'action':action,'exit_status':p.returncode,'result':'FAILED','diagnostic_private':True}))
sys.exit(p.returncode or 1)
data=json.loads(lines[0].split('=',1)[1])
if action=='create':
value={**data['public'],**data['private']}
resources.write_text(json.dumps(value,ensure_ascii=False,indent=2)+'\n');resources.chmod(0o600)
env='DIFY_ASR_APP_KEY='+value['api_key']+'\nDIFY_ASR_APP_ID='+value['app_id']+'\nDIFY_ASR_BASE_URL='+value['base_url']+'\nDIFY_ASR_EXPECTED_MODEL=Qwen/Qwen3-ASR-1.7B\nDIFY_EXTRACTION_EXPECTED_MODEL=qwen3.6-35b\nDIFY_BINDING_REVISION=followup-dify-20261009-v1-'+value['config_sha256'][:16]+'\n'
(BASE/'dify-runtime.env').write_text(env);(BASE/'dify-runtime.env').chmod(0o600)
print(json.dumps({'action':action,'exit_status':p.returncode,'result':'PASS',**data['public']},ensure_ascii=False))
@@ -0,0 +1,42 @@
"""Synthetic-only HTTPS acceptance; Python 3.6+, never disables TLS checks."""
import hashlib,json,pathlib,re,ssl,sys,time,urllib.error,urllib.request,uuid
ENV=pathlib.Path(sys.argv[1])
WAV=pathlib.Path(sys.argv[2])
MODE=sys.argv[3] if len(sys.argv)>3 else 'all'
CANARIES=json.loads(pathlib.Path(sys.argv[4]).read_text(encoding='utf-8')) if len(sys.argv)>4 else ['紫色海豚','金色河流','银色树叶','红色石榴']
env=dict(line.split('=',1) for line in ENV.read_text(encoding='utf-8').splitlines() if '=' in line)
base=env['DIFY_ASR_BASE_URL'];key=env['DIFY_ASR_APP_KEY']
ctx=ssl.create_default_context()
def request(path,data,content_type,auth=True):
h={'Content-Type':content_type}
if auth:h['Authorization']='Bearer '+key
req=urllib.request.Request(base+path,data=data,headers=h,method='POST')
start=time.monotonic()
try:
with urllib.request.urlopen(req,context=ctx,timeout=200) as response:
code=response.status;body=response.read().decode()
except urllib.error.HTTPError as error:
code=error.code;body=error.read().decode()
return {'status':code,'elapsed_seconds':round(time.monotonic()-start,3),'body':json.loads(body)}
def audio():
boundary='synthetic'+uuid.uuid4().hex
body=('--'+boundary+'\r\nContent-Disposition: form-data; name="user"\r\n\r\nsynthetic-asr-acceptance-20261009\r\n--'+boundary+'\r\nContent-Disposition: form-data; name="file"; filename="synthetic.wav"\r\nContent-Type: audio/wav\r\n\r\n').encode()+WAV.read_bytes()+('\r\n--'+boundary+'--\r\n').encode()
return request('/audio-to-text',body,'multipart/form-data; boundary='+boundary)
r={'synthetic':True,'tls_verify':True,'check_hostname':ctx.check_hostname,'ssl':ssl.OPENSSL_VERSION,'mode':MODE,'app_id':env['DIFY_ASR_APP_ID'],'base_url':base,'binding_revision':env['DIFY_BINDING_REVISION']}
if MODE in ('all','asr'):
r['unauthorized']=request('/audio-to-text',b'','application/octet-stream',False)
r['asr']=audio()
text=r['asr']['body'].get('text','')
r['audio_only_canaries']={x:x in text for x in CANARIES}
r['asr_pass']=r['unauthorized']['status']==401 and r['asr']['status']==200 and all(r['audio_only_canaries'].values())
if MODE in ('all','chat'):
query='这是独立的合成 JSON 验收,不包含患者数据。请只输出 JSON 对象:{"canary":"BLUE_KITE_20261009","sum":42,"preview_only":true}。不要添加其他字段、解释或 Markdown。'
payload={'inputs':{},'query':query,'response_mode':'blocking','user':'synthetic-chat-acceptance-20261009','auto_generate_name':False}
r['chat']=request('/chat-messages',json.dumps(payload,ensure_ascii=False).encode(),'application/json')
answer=r['chat']['body'].get('answer','')
try:parsed=json.loads(answer)
except ValueError:parsed=None
r['chat_pass']=r['chat']['status']==200 and parsed=={'canary':'BLUE_KITE_20261009','sum':42,'preview_only':True} and bool(r['chat']['body'].get('message_id')) and '<think>' not in answer
r['passed']=all(r.get(k,True) for k in ['asr_pass','chat_pass'])
print(json.dumps(r,ensure_ascii=True,indent=2))
sys.exit(0 if r['passed'] else 1)
@@ -0,0 +1,56 @@
"""Local synthetic mock-server tests, no model calls or secrets."""
import http.client
import json
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import bridge
KEY = b'synthetic-only-unit-test-secret'
class Upstream(BaseHTTPRequestHandler):
calls = 0
slow = False
entered = threading.Event()
def log_message(self, *args): pass
def do_GET(self):
body = b'{}'; self.send_response(200); self.send_header('Content-Length','2'); self.end_headers(); self.wfile.write(body)
def do_POST(self):
type(self).calls += 1
type(self).entered.set()
self.rfile.read(int(self.headers['Content-Length']))
if type(self).slow: time.sleep(0.25)
assert self.headers['Authorization'] == 'Bearer ' + KEY.decode()
body = b'{"text":"synthetic-only-canary"}'
self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
class BridgeTests(unittest.TestCase):
def setUp(self):
Upstream.calls=0;Upstream.slow=False;Upstream.entered.clear()
self.u=ThreadingHTTPServer(('127.0.0.1',0),Upstream)
self.b=bridge.Server(('127.0.0.1',0),bridge.Handler,key=KEY,upstream=self.u.server_address)
for s in [self.u,self.b]: threading.Thread(target=s.serve_forever,daemon=True).start()
def tearDown(self):
for s in [self.b,self.u]:s.shutdown();s.server_close()
def call(self,body=b'x',headers=None,path='/v1/audio/transcriptions',method='POST'):
h={'Authorization':'Bearer '+KEY.decode(),'Content-Type':'multipart/form-data; boundary=test'}
if headers: h.update(headers)
c=http.client.HTTPConnection(*self.b.server_address,timeout=2);c.request(method,path,body,h);r=c.getresponse();result=(r.status,r.read());c.close();return result
def test_valid_fixed_upstream(self):
self.assertEqual(self.call(),(200,b'{"text":"synthetic-only-canary"}'));self.assertEqual(Upstream.calls,1)
def test_auth_denied_without_upstream(self):
self.assertEqual(self.call(headers={'Authorization':'Bearer wrong'})[0],401);self.assertEqual(Upstream.calls,0)
def test_body_bound_before_read(self):
self.assertEqual(self.call(headers={'Content-Length':str(bridge.MAX_BODY+1)})[0],413);self.assertEqual(Upstream.calls,0)
def test_fixed_path_only(self):
self.assertEqual(self.call(path='/v1/chat/completions')[0],404);self.assertEqual(Upstream.calls,0)
def test_concurrency_exactly_one(self):
Upstream.slow=True;out=[];t=threading.Thread(target=lambda:out.append(self.call()[0]));t.start();self.assertTrue(Upstream.entered.wait(1));self.assertEqual(self.call()[0],429);t.join();self.assertEqual(out,[200]);self.assertEqual(Upstream.calls,1)
def test_rate_429_no_retry(self):
self.b.gate=bridge.Gate(rate=0,burst=1);self.assertEqual(self.call()[0],200);self.assertEqual(self.call()[0],429);self.assertEqual(Upstream.calls,1)
def test_upstream_down_no_retry(self):
self.b.upstream=('127.0.0.1',1);self.assertEqual(self.call()[0],502);self.assertEqual(Upstream.calls,0)
def test_wrong_content_type(self):
self.assertEqual(self.call(headers={'Content-Type':'application/json'})[0],415);self.assertEqual(Upstream.calls,0)
if __name__=='__main__':unittest.main(verbosity=2)
@@ -0,0 +1,40 @@
# Follow-up preview runtime (2026-10-09)
This is an opt-in, diagnosis-ID-scoped preview. It never permits clinical adoption; full audio/semantic verification remains false. Do not use a preview fingerprint as full verification. Preview-origin tasks remain non-adoptable after configuration changes.
## Dedicated media tools
The CentOS 7 Webhost needs a private FFmpeg/FFprobe path, not a system upgrade. Installed location is `/opt/followup-audio-tools/ffmpeg-9.0.2/` (no PATH or OS package changes). Source was downloaded from `https://ffmpeg.org/releases/ffmpeg-9.0.2.tar.xz`, SHA256 `8c3850283eb25fa026482078a04051e0be17347b09ef81a0849bec15a96e002e`, with detached PGP signature verified against the official release key `FCF986EA15E6E293A5644F10B4322F04D67658D8`.
It was built on the AI host using GCC 12, `nice -n 19 make -j1`, static libc, generic x86-64 / Linux 3.2 baseline, without network protocols or optional external codec libraries. Build flags:
```sh
./configure --prefix=/opt/followup-audio-tools/ffmpeg-9.0.2 \
--disable-autodetect --disable-shared --enable-static --extra-cflags=-O2 --extra-ldflags=-static \
--disable-x86asm --disable-doc --disable-debug --disable-network --disable-everything \
--enable-ffmpeg --enable-ffprobe --enable-avcodec --enable-avformat --enable-avfilter --enable-swresample \
--enable-protocol=file,pipe --enable-demuxer=mov,mp3,wav,amr,aac \
--enable-parser=aac,aac_latm,mpegaudio \
--enable-decoder=mp3,mp3float,mp3adu,mp3adufloat,mp3on4,mp3on4float,aac,aac_latm,amrnb,amrwb,pcm_s16le,pcm_s16be,pcm_u8,pcm_s24le,pcm_s32le,pcm_f32le,pcm_f64le \
--enable-encoder=pcm_s16le --enable-muxer=wav \
--enable-filter=aresample,pan,anull,aformat,atrim,asetpts,abuffer,abuffersink
nice -n 19 make -j1 ffmpeg ffprobe
```
Binary SHA256: ffmpeg `a2c81c9049a5e919d8f5ce9c246580f9f6cf8a38aaece8c79ed0fd893c35453e`; ffprobe `af8f5eb67ea87beca9ec7fb27f3355cf918fabee0d88a7db628bf511c691a680`. Both executed successfully on the Webhost; synthetic stereo WAV was decoded and split. This minimal build is for the PCM two-stage driver, not an assertion that legacy MP3 re-encoding, all media encodings, or real hour-long ASR has passed.
## Dify and TLS
Use the isolated App provisioned by `../followup-audio-dify/`, HTTPS only. On this host PHP cURL/NSS rejects the current certificate; native PHP OpenSSL verifies it successfully. Set the feature's explicit `HTTP_TRANSPORT=openssl_stream`, not TLS verification off and not automatic fallback. The controlled child process maintains parent authorization/lease heartbeats. Other application HTTP clients are unchanged.
The Dify App owns model/generation parameters. Configure both explicit stage protocols, expected models and binding revisions; changing App/default/provider configuration requires a new revision and preview verification. Keep extraction thinking unset locally and `EXTRACTION_RESPONSE_FORMAT=prompt_json`; no claim that local OpenAI response_format/max_tokens are transmitted through Dify.
## Release constraints
- `ENABLED=true` only with `PREVIEW_ONLY=true`, explicit `TEST_DIAGNOSIS_IDS`, fresh preview fingerprint, stable private encryption key; `AUDIO_VERIFIED=false`.
- Use an independent `followup-audio-preview` consumer, concurrency 1. Do not restart prescription workers, PHP, Dify or GPU services.
- Back up source/env/static assets and affected schema/data. Additive DDL must use a bounded session lock budget; prefer explicit INSTANT columns and fail instead of silently table-copying.
- Keep existing untracked production overlays. Coordinate the two existing auto-pull lock files; never reset/clean the live repository.
- Build with `vite build` only. Copy new hashed assets additively, retain old assets for active browsers, then atomically replace index.html; do not invoke the destructive release.mjs on the live directory.
- Rollback stops only the new consumer and disables preview, restores changed source/index/env with hashes, and retains additive tables/columns/audit. Never roll back the entire business database or rewrite remote master to undo a release.
- Keep API credentials, raw recordings, clinical snapshots and runtime state outside Git. Application cleanup does not imply Dify copies were deleted.