feat: add scoped read-only audio preview and isolated Dify transport
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user