"""SQLite lease corpus / standard Skill packages API (BR-61..65).""" import os from urllib.parse import urlsplit from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, Response from api.risk_agent_access import authorize, check_project from api.project_assistant import enabled from contracts.lease_library import QueryInput, SelectionInput, ExtractionInput, PackageInput, ModelSettingsInput from services.project_assistant import ProjectAssistantService from services import assistant_store as store, component_registry as registry from services import lease_case_library as library, lease_extraction, skill_packages from services.assistant_materials import FIELDS from services.assistant_settings import flags from services import experience_extraction router = APIRouter(prefix='/api/xingbang/risk-agent/project-assistant/library', dependencies=[Depends(authorize), Depends(enabled)], tags=['兴邦对公租赁经验库']) def service(): svc = ProjectAssistantService() library.initialize(svc.db_path) from services.catalog_configuration import seed_fact_source with svc.connect() as db: seed_fact_source(db) return svc def guard(request, editing=False): identity = request.state.identity if identity['role'] not in ('manager', 'admin') or (editing and identity['role'] != 'admin'): raise HTTPException(403, '当前身份无权进行经验加工') if not identity.get('demo') or not flags()['demo']: raise HTTPException(403, '本案例库属于模拟数据域,请使用本机演示身份') return identity['id'] def project(db, request, project_id): check_project(request, project_id) body = store.get(db, 'project', project_id) if body['data_domain'] != 'demo': raise HTTPException(403, '模拟历史经验不能写入真实数据域') return body def translate(call): try: return call() except library.LibraryError as exc: code = 409 if exc.code == 'LIBRARY_SELECTION_INVALID' else 422 return JSONResponse(status_code=code, content={'error': {'code': exc.code, 'message': str(exc), 'retryable': False}}) except KeyError: raise HTTPException(404, '对象不可访问或不存在') from None except store.Conflict as exc: raise HTTPException(409, str(exc)) from exc except (ValueError, PermissionError) as exc: raise HTTPException(422 if isinstance(exc, ValueError) else 403, str(exc)) from exc @router.get('/cases') def cases(request: Request): guard(request) def action(): with service().connect() as db: rows = library.read_query(db, 'SELECT * FROM lease_cases ORDER BY id') return {'items': rows, 'count': len(rows), 'total': len(rows), 'schema': list(library.COLUMNS), 'source': library.SOURCE, 'model_available': experience_extraction.resolve_model_config(None).get('available', False)} return translate(action) @router.post('/queries') def query(payload: QueryInput, request: Request): actor = guard(request, True) def action(): with service().connect() as db: return library.preview(db, actor, payload.mode, payload.text, payload.filters) return translate(action) @router.post('/selections', status_code=201) def selection(payload: SelectionInput, request: Request): actor = guard(request, True) def action(): with service().connect() as db: return library.confirm(db, actor, payload.preview_id, payload.selected_ids) return translate(action) @router.post('/extractions', status_code=201) def extraction(payload: ExtractionInput, request: Request): actor = guard(request, True) def action(): svc = service() with svc.connect() as db: context = project(db, request, payload.project_id) confirmed, items = library.selected_cases(db, actor, payload.selection_id) proposal, trace, failure = None, None, None try: proposal, trace = lease_extraction.invoke(context, confirmed, items, payload.kind, payload.requirement) except Exception: # Do not return model HTTP bodies/credentials to the browser. failure = '模型未配置、调用未完成或输出未通过契约校验' with svc.connect() as db: library.selected_cases(db, actor, payload.selection_id) return lease_extraction.save(db, context, confirmed, items, actor, payload.kind, payload.requirement, proposal, trace, failure) return translate(action) def card(db, request, card_id): try: row = store.get(db, 'component_draft' if '@' in card_id else 'release', card_id) if '@' not in card_id: row = registry.resolve(db, card_id) except KeyError: row = store.get(db, 'component_version', card_id) if row.get('scope') != 'demo': raise HTTPException(403, '卡片数据域不可访问') if row.get('project_id'): check_project(request, row['project_id']) return row @router.get('/cards') def cards(project_id: str, request: Request): guard(request) def action(): with service().connect() as db: project(db, request, project_id) released = [registry.resolve(db, p['id']) for p in store.listing(db, 'release')] published = {(r['id'],r['version']) for r in released} drafts = [r for r in store.listing(db, 'component_draft', project_id) if (r.get('component_id'),r['version']) not in published] items = [r for r in released + drafts if r.get('scope') == 'demo' and r.get('kind') in ('skill', 'rule_set', 'data_card', 'template') and r.get('project_id') in (None, '', project_id) and r.get('id') != 'review.analysis'] from services.catalog_configuration import hidden items = [dict(r, hidden=hidden(db, r.get('component_id') or r['id'])) for r in items if r.get('audience') != ['reviewer']] if request.state.identity['role'] != 'admin': items = [r for r in items if r['status']=='published' and not r['hidden']] for row in items: if row['kind'] == 'skill': row['package'] = skill_packages.standard_package(db, row) return {'items': items, 'fields': sorted(set(FIELDS.values()))} return translate(action) @router.get('/cards/{card_id}') def card_detail(card_id: str, request: Request): guard(request) def action(): with service().connect() as db: return card(db, request, card_id) return translate(action) @router.post('/skills/import', status_code=201) def import_skill(payload: PackageInput, request: Request): actor = guard(request, True) def action(): with service().connect() as db: context = project(db, request, payload.project_id) return skill_packages.import_package(db, context, actor, [f.model_dump() for f in payload.files], payload.required_field) return translate(action) @router.get('/skills/{card_id}/package') def package(card_id: str, request: Request): guard(request) def action(): with service().connect() as db: component = card(db, request, card_id) if component['kind'] != 'skill': raise ValueError('仅 Skill 卡片具有标准文件包') return skill_packages.content(db, component) return translate(action) @router.get('/skills/{card_id}/download') def download(card_id: str, request: Request): guard(request) def action(): with service().connect() as db: component = card(db, request, card_id) if component['kind'] != 'skill': raise ValueError('仅 Skill 可下载标准包') name, binary = skill_packages.archive(db, component) return Response(binary, media_type='application/zip', headers={'Content-Disposition': f'attachment; filename="{name}.zip"'}) return translate(action) @router.post('/skills/{card_id}/open') def open_package(card_id: str, request: Request): actor = guard(request, True) hostname = urlsplit('//'+request.headers.get('x-xingbang-client-host', request.headers.get('host', ''))).hostname local_demo = request.client and request.client.host in ('127.0.0.1', '::1', 'testclient') and hostname in ('127.0.0.1', 'localhost', '::1', 'testserver') and not os.getenv('XINGBANG_AUTH_USERS') and os.getenv('XINGBANG_REQUIRE_AUTH') != '1' if not (request.state.identity.get('preview') or local_demo): raise HTTPException(403, '打开文件夹仅支持已确认的本机预览;其他部署请下载标准包') def action(): with service().connect() as db: result = skill_packages.open_folder(db, card(db, request, card_id)) store.event(db, '', 'skill.folder_open', actor, {'card_id': card_id}) return result return translate(action) @router.get('/plans/{plan_id}') def plan_components(plan_id: str, project_id: str, request: Request): guard(request) def action(): from services import review_plan from services.assistant_data import load_catalog with service().connect() as db: context = project(db, request, project_id) frozen = review_plan.assemble(db, plan_id, load_catalog()) if frozen['plan']['scope'] != context['data_domain']: raise HTTPException(403, '方案数据域不匹配') if frozen['plan'].get('project_id'): check_project(request, frozen['plan']['project_id']) return {'plan': frozen['plan'], 'skills': frozen['skills'], 'rules': frozen['rules'], 'cards': frozen.get('legacy_cards', []), 'template': frozen['template'], 'content_hash': frozen['content_hash']} return translate(action) @router.get('/model-settings') def model_status(request: Request): guard(request, True) from services import model_settings return model_settings.status() @router.put('/model-settings') def save_model_settings(payload: ModelSettingsInput, request: Request): actor=guard(request, True) from services import model_settings try: result=model_settings.save(payload.endpoint,payload.name,payload.api_key) except ValueError as exc: code=str(exc) message='模型由环境配置管理,请联系部署管理员修改' if code=='MODEL_ENV_LOCKED' else '模型设置不合法,请使用受信地址和有效模型名称' return JSONResponse(status_code=409 if code=='MODEL_ENV_LOCKED' else 422,content={'error':{'code':code,'message':message}}) with service().connect() as db: store.event(db,'','model.settings_updated',actor,{'managed_by':result['managed_by']}) return result @router.post('/model-settings/probe') def probe_model(request: Request): actor=guard(request, True) from services import model_settings result=model_settings.probe() with service().connect() as db: store.event(db,'','model.connection_checked',actor,{'ok':result['ok']}) return result @router.get('/projects/{project_id}/feedback') def meeting_feedback(project_id: str,request: Request): guard(request) def action(): from services.meeting_feedback import summarize with service().connect() as db: project(db,request,project_id) return summarize(db,project_id) return translate(action)