commit d6d92fd966ce6aef3480b3976adacbd4c1625bdc Author: Codex Agent Date: Wed Oct 22 19:59:48 2025 +0800 chore: initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f869654 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.log +.DS_Store +Thumbs.db +.env.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3923e85 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- Root scripts: smart_cors_middleware.py (Flask CORS add-on), export_risk_json.py (PostgreSQL export). +- Data/outputs: risk_tables_export.json (generated by export script). +- Docs: PRD.md. +- Python 3.10+ is required (uses PEP 604 unions like str | None). + +## Build, Test, and Development Commands +- Create venv (Windows): + ~~~powershell + python -m venv .venv; .venv\Scripts\activate; pip install Flask pg8000 black ruff pytest + ~~~ +- Run DB export (writes risk_tables_export.json): + ~~~bash + python export_risk_json.py + ~~~ +- Verify CORS middleware in your Flask app (diagnosis endpoint): + ~~~bash + curl -i http://localhost:5000/api/cors-diagnosis + ~~~ +- Lint/format (optional tools): ruff . and black . +- Tests (when added): pytest -q + +## Coding Style & Naming Conventions +- Python: 4-space indents, UTF-8 files, snake_case for functions/vars, SCREAMING_SNAKE_CASE for constants. +- Prefer type hints; keep functions small and side-effect free. +- Formatting: black (line length 100). Linting: ruff (default rules). +- Filenames: modules like smart_cors_middleware.py; tests as test_*.py under tests/. + +## Testing Guidelines +- Framework: pytest with Flask test client for middleware behavior. +- Target cases: origin matching (wildcard, exact, subdomains), preflight handling, X-CORS-Decision header, NGINX_CORS_MODE behavior. +- Coverage: prioritize core branches in _origin_matches, preflight (OPTIONS), and after_request logic. + +## Commit & Pull Request Guidelines +- No Git history found here; use Conventional Commits (e.g., feat: add CORS diagnosis endpoint). +- PRs should include: purpose, concise summary, screenshots or curl examples for HTTP changes, and any config/env notes. +- Link related issues; keep PRs focused and under ~300 changed lines when possible. + +## Security & Configuration Tips +- Do NOT hardcode secrets. Move DB credentials in export_risk_json.py to env vars and load via os.getenv() or a .env file. +- CORS env vars supported by middleware: ALLOWED_ORIGINS, CORS_STRICT, CORS_DEBUG, NGINX_CORS_MODE, CORS_MAX_AGE, CORS_EXPOSE_HEADERS. +- Validate inputs from the DB export; avoid writing outside the repo. diff --git a/API.md b/API.md new file mode 100644 index 0000000..6af6a00 --- /dev/null +++ b/API.md @@ -0,0 +1,86 @@ +# LawRisk 检索接口文档 + +- Base URL: https://YOUR_HOST +- 路径: /fs-ai-asistant/api/workflow/lawrisk +- 方法: POST(推荐), GET(便捷调试) +- 鉴权: 无(如需可在网关/反向代理层添加) +- CORS: 已启用(复用 smart_cors_middleware.py) + +## 请求格式 +- Content-Type: application/x-www-form-urlencoded +- 表单字段 + - query | q | text (string,必填): 用户输入的中文问题 + - mode (string,可选): llm(默认) 或 embed + - debug (boolean-like,可选): 1/true/yes/on 视为开启调试 + - top (int,可选): 调试时返回候选数量(默认 5) + +提示: GET 模式下同名参数通过查询串传递;POST 模式优先解析 x-www-form-urlencoded,若未提供则回退 JSON(application/json)。 + +## 响应 +- 成功 (200) + - risk_subject: 数组,每项包含 + - id (string) + - name (string) + - permit_ids (string[]) + - score (number,可选,仅在 debug=1 且 embed 模式或回退时出现) + - debug (object,可选,debug=1 时返回) + - model (string): 使用模型(如 qwen-plus-latest) + - num_subjects (number): 参与检索的主题数量 + - selected_ids (string[], 仅 llm 模式): LLM 选择的主题 ID 列表 + - thresholds (object, 仅 embed 模式): 相似度阈值 + - top_candidates (array, 仅 embed 模式): 前 N 候选及分数 + - allow_empty (boolean): LLM 允许返回空结果 +- 失败 + - 400: { "error": "query is required" } + - 500: { "error": "<错误信息>" } + +## 示例 +POST(推荐,LLM 模式 + 调试) + +curl -s -X POST "http://www.chinaweal.com.cn:8090/fs-ai-asistant/api/workflow/lawrisk" -H "Content-Type: application/x-www-form-urlencoded" -d "query=我要办一家电影院&mode=llm&debug=1&top=5" + +示例响应(命中) + +{ + "risk_subject": [ + {"id":"384a...05e7","name":"开办电影院","permit_ids":["04bf...","509b...","..."]} + ], + "debug": { + "model": "qwen-plus-latest", + "num_subjects": 123, + "selected_ids": ["384a...05e7"], + "allow_empty": true + } +} + +示例响应(无匹配,允许空) + +{ + "risk_subject": [], + "debug": { + "model": "qwen-plus-latest", + "num_subjects": 123, + "selected_ids": [], + "allow_empty": true + } +} + +GET 便捷调试 + +curl -s "http://www.chinaweal.com.cn:8090T/fs-ai-asistant/api/workflow/lawrisk?query=%E6%88%91%E8%A6%81%E5%8A%9E%E4%B8%80%E5%AE%B6%E7%94%B5%E5%BD%B1%E9%99%A2&mode=llm&debug=1&top=5" + +前端调用示例(fetch) + +fetch("http://www.chinaweal.com.cn:8090/fs-ai-asistant/api/workflow/lawrisk", { + method: "POST", + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + body: new URLSearchParams({ query: "我要办一家电影院", mode: "llm", debug: "1", top: "5" }) +}).then(r => r.json()).then(console.log) + +## 模式说明 +- llm(默认):将主题清单(id 与名称)传给 Qwen(qwen-plus-latest),由 LLM 选择最相关的一个或多个主题 ID;若判断无匹配,返回空数组。 +- embed(可选):基于向量相似度检索;阈值可通过环境变量配置(LAWRISK_RETURN_IF_GE、LAWRISK_FALLBACK_GT)。 + +## 兼容与跨域 +- 服务端已启用 CORS,可在 .env 中配置:ALLOWED_ORIGINS、CORS_STRICT、CORS_DEBUG、NGINX_CORS_MODE 等。 +- 如需鉴权(例如加 Token),建议在网关或反代层统一处理。 diff --git a/DB_GUIDE.md b/DB_GUIDE.md new file mode 100644 index 0000000..309558d --- /dev/null +++ b/DB_GUIDE.md @@ -0,0 +1,73 @@ +# Database Schema & Query Guide + +## Overview +The `licensing_risks` PostgreSQL database stores municipal licensing risk prompts parsed from Excel workbooks. Each record links regions, themes, permits, and risk narratives so downstream systems can query compliance obligations quickly. + +## Tables +| Table | Purpose | Key Columns | +| --- | --- | --- | +| `regions` | Administrative areas (市级、禅城区等) | `id` (PK), `name` (unique) | +| `business_scopes` | Scoped经营范围条目 | `id` (PK), `description` | +| `region_scopes` | Region-to-scope mapping | `region_id` → `regions.id`, `scope_id` → `business_scopes.id` | +| `themes` | “一照通行”主题事项 | `id` (PK), `name` | +| `region_themes` | Region-to-theme mapping | `region_id`, `theme_id` | +| `permits` | 许可(备案)事项 | `id` (PK), `name` | +| `region_theme_permits` | Region + theme + permit linkage | `region_id`, `theme_id`, `permit_id` | +| `risks` | 风险提示主体信息 | `id` (PK), `risk_content`, `legal_basis`, `document_no`, `summary` | +| `region_permit_risks` | Region + permit + risk linkage | `region_id`, `permit_id`, `risk_id` | + +All primary keys are integer sequences; unique indexes and `ON CONFLICT DO NOTHING` logic make repeated imports idempotent. Foreign keys should be enforced in the target schema to prevent orphan rows. + +## Query Cheatsheet +### 列出所有主题事项(总表) +```sql +SELECT t.id, + t.name AS theme_name, + r.name AS region_name +FROM themes t +JOIN region_themes rt ON rt.theme_id = t.id +JOIN regions r ON r.id = rt.region_id +ORDER BY r.name, t.name; +``` + +### 根据主题事项获取许可事项列表 +Replace `%主题关键词%` with the desired theme name or keyword. +```sql +SELECT DISTINCT p.id, + p.name AS permit_name, + r.name AS region_name +FROM permits p +JOIN region_theme_permits rtp + ON rtp.permit_id = p.id +JOIN region_themes rt + ON rt.region_id = rtp.region_id + AND rt.theme_id = rtp.theme_id +JOIN themes t ON t.id = rt.theme_id +JOIN regions r ON r.id = rt.region_id +WHERE t.name ILIKE '%主题关键词%' +ORDER BY r.name, permit_name; +``` + +### 根据许可事项检索风险条目 +Substitute `'具体许可名称'` with the permit you care about. +```sql +SELECT r.name AS region_name, + p.name AS permit_name, + rk.risk_content, + rk.legal_basis, + rk.document_no, + rk.summary +FROM region_permit_risks rpr +JOIN regions r ON r.id = rpr.region_id +JOIN permits p ON p.id = rpr.permit_id +JOIN risks rk ON rk.id = rpr.risk_id +WHERE p.name = '具体许可名称' +ORDER BY r.name, rk.risk_content; +``` +For fuzzy lookups, switch to `WHERE p.name ILIKE '%关键词%'`. + +## Execution Tips +- Connect via `psql -h 172.24.240.1 -U postgres -d licensing_risks`. +- Export query results with `\copy (SELECT …) TO '/tmp/export.csv' WITH CSV HEADER;`. +- Run queries after imports commit; the loaders already wrap operations in transactions. + diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..7b5d633 --- /dev/null +++ b/PRD.md @@ -0,0 +1,72 @@ +我需要你帮我构建一个检索系统,用户会输入问题(中文),期望匹配到对应的事项,输出事项ID 事项名称 许可事项列表,例如: + +用户输入:我要办一家电影院 + +输出: + +  "risk\_subject": \[ + +  { + +  "id": "384aeb24a23e913268aad33354f705e7", + +  "name": "开办电影院", + +  "permit\_ids": \[ + +  "04bfa019634ca1aa0b9f7c783fd85dce", + +  "509b2872fc7c38c08f252a2b426fd49f", + +  "54a79077-bd72-4ea9-8bb1-35afc69e2973", + +  "709b4718d72229311066e529650b8abf", + +  "8d49de002f24d37fcf3663574723e693", + +  "8f7c8c613adfbd815a78c1e60ec4330e", + +  "a0572119839422e1d11ee8801d6c58b7", + +  "fa2f3e05c92297be096b63e25d30bfbe" + +  ] + +  }] + +我希望你能用embedding模型来处理 + +首先先把事项名称从risk\_tables\_export.json 中提取出来,然后建立一个fs\_law\_risk数据库,建立表law\_sub用来存放事项向量,以json文件中的ID为主键,保存名称和向量到数据库 + +再建立一个表,名为law\_sub\_per,保存主题事项与许可事项的映射关系,需要有主题事项id,许可事项id列表 + +设置embedding相似度阈值0.5,大于阈值以上的事项全部返回 + +如果检索结果都小于0.5,但大于0.4,返回第一个 + +暴露接口/fs-ai-asistant/api/workflow/lawrisk + +跨域问题处理请复用:smart\_cors\_middleware.py,你可以把这个文件移动到合适的目录 + +* 你可以使用的postgreSQL: + +  - IP :8.138.196.105 + +  - port:5432 + +  - user:postgres + +  - password:difyai123456 + +* API 以及doc参考 + +我们应该只需要用同步接口 + +  - 通用文本向量同步接口API详情:https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api?spm=a2c4g.11186623.help-menu-2400256.d\_2\_7\_0.693e48233phHX8 + +  - 通用文本批处理接口API详情:https://help.aliyun.com/zh/model-studio/text-embedding-batch-api?spm=a2c4g.11186623.help-menu-2400256.d\_2\_7\_1.59233560WBHuRz + +  - API key:sk-288824ef003e4e02bb963b8b3024b06a + + + diff --git a/app.py b/app.py new file mode 100644 index 0000000..d57c378 --- /dev/null +++ b/app.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import os +from flask import Flask, jsonify, request + +from env_loader import load_env +from smart_cors_middleware import init_smart_cors +import time +from concurrent.futures import ThreadPoolExecutor +from lawrisk_service import ( + ensure_database, + ensure_schema, + search_subjects, + search_subjects_llm, + shortlist_subjects, + suggest_questions_from_subjects, + suggest_questions_embed, +) + + +def create_app() -> Flask: + # Load .env before creating app to make CORS/env configs available + load_env() + # Ensure DB and schema exist before serving + try: + ensure_database() + ensure_schema() + except Exception: + # Do not block app start; errors will surface on first request + pass + app = Flask(__name__) + # Enable CORS using existing middleware + init_smart_cors(app) + + @app.route("/fs-ai-asistant/api/workflow/lawrisk", methods=["POST", "GET"]) + def lawrisk_search(): + if request.method == "GET": + query = request.args.get("query") or request.args.get("q") or request.args.get("text") + debug_flag = request.args.get("debug") in {"1", "true", "yes", "on"} + top_k = request.args.get("top") + try: + top_k_int = int(top_k) if top_k else 5 + except Exception: + top_k_int = 5 + mode = (request.args.get("mode") or "llm").lower() + else: + # Prefer x-www-form-urlencoded; fallback to JSON if provided + if request.is_json: + payload = request.get_json(silent=True) or {} + else: + payload = request.form.to_dict(flat=True) if request.form else {} + + query = payload.get("query") or payload.get("q") or payload.get("text") + debug_flag = str(payload.get("debug", "")).strip().lower() in {"1", "true", "yes", "on"} + try: + top_k_int = int(payload.get("top", 5)) + except Exception: + top_k_int = 5 + mode = str(payload.get("mode", "llm")).lower() + + if not query or not isinstance(query, str): + return jsonify({"error": "query is required"}), 400 + try: + t0 = time.time() + with ThreadPoolExecutor(max_workers=3) as ex: + fut_ret = ex.submit( + search_subjects if mode == "embed" else search_subjects_llm, + query, + debug_flag, + top_k_int, + ) + # Use embedding-based question suggestion (falls back internally if not available) + fut_qs = ex.submit(suggest_questions_embed, query, max(1, top_k_int)) + + result = fut_ret.result() + rec_questions = fut_qs.result() or [] + + # If debug requested, still log to backend for visibility + if debug_flag and isinstance(result, dict) and "debug" in result: + dbg = result["debug"] + model = dbg.get("model") or "embed" + app.logger.info("[LAWRISK-DEBUG] mode=%s", model) + + # Extract risk_subject and optional debug + risk_subject = [] + dbg = {} + if isinstance(result, dict): + risk_subject = result.get("risk_subject", []) + if debug_flag: + dbg = result.get("debug", {}) + + found = bool(risk_subject) + llm_resp = "" if found else "抱歉,无法检索到相关答案" + exec_time = int((time.time() - t0) * 1000) + + # rec_questions 已由 embedding 建议生成(内部包含兜底) + + data = { + "llmRespond": llm_resp, + "lawRisk": "", + "questionExtend": rec_questions, + "conversationId": "", + "messageId": "", + "roundNumber": 0, + "conversationInfo": {}, + "knowledgeSources": [], + "totalKnowledgeSources": 0, + "executionTime": exec_time, + "workflowStatus": "ok" if found else "no_match", + "executionSteps": [], + "costStatistics": {}, + "workflowTrackingId": "", + # extra fields requested + "risk_subject": risk_subject, + "debug": dbg if debug_flag else {}, + } + resp = {"success": True, "message": "OK", "data": data} + return jsonify(resp) + except Exception as e: + app.logger.exception("lawrisk_search error") + return jsonify({"success": False, "message": str(e), "data": {}}), 500 + + # Basic health check + @app.get("/healthz") + def healthz(): + return jsonify({"status": "ok"}) + + return app + + +if __name__ == "__main__": + port = int(os.getenv("PORT", "8000")) + app = create_app() + app.run(host="0.0.0.0", port=port) diff --git a/env_loader.py b/env_loader.py new file mode 100644 index 0000000..08c1069 --- /dev/null +++ b/env_loader.py @@ -0,0 +1,36 @@ +"""Minimal .env loader to populate os.environ from a .env file. + +Supports lines of the form KEY=VALUE, optional quotes, and comments (# ...). +By default, does not override existing environment variables. +""" +from __future__ import annotations + +import os +from typing import Optional + + +def load_env(path: str = ".env", override: bool = False) -> None: + if not os.path.exists(path): + return + try: + with open(path, "r", encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, val = line.split("=", 1) + key = key.strip() + val = val.strip() + # Strip quotes if wrapped + if (val.startswith("\"") and val.endswith("\"")) or ( + val.startswith("'") and val.endswith("'") + ): + val = val[1:-1] + if key and (override or key not in os.environ): + os.environ[key] = val + except Exception: + # Fail silently; callers still can rely on existing env + return + diff --git a/export_risk_json.py b/export_risk_json.py new file mode 100644 index 0000000..6ebb4b9 --- /dev/null +++ b/export_risk_json.py @@ -0,0 +1,75 @@ +import json +import os +import sys +import pg8000 +from env_loader import load_env + +# Read DB config from environment; provide sensible defaults +CONFIG = { + 'host': os.getenv('PG_HOST', '8.138.196.105'), + 'port': int(os.getenv('PG_PORT', '5432')), + 'database': os.getenv('PG_DATABASE', 'fs_law_risk'), + 'user': os.getenv('PG_USER', 'postgres'), + 'password': os.getenv('PG_PASSWORD', 'difyai123456'), +} + +# Export file path can be overridden via env +OUTPUT = os.getenv('RISK_EXPORT_OUTPUT', 'risk_tables_export.json') + +SQLS = { + 'risk_subject': "SELECT sub_id AS id, sub_name AS name FROM public.risk_subject ORDER BY sub_name;", + 'risk_permit': "SELECT per_id AS id, per_name AS name FROM public.risk_permit ORDER BY per_name;", + 'risk_sub_per': "SELECT sub_id, per_id FROM public.risk_sub_per;", +} + +def fetch_all(cursor): + cols = [d[0] for d in cursor.description] + return [dict(zip(cols, row)) for row in cursor.fetchall()] + +def main(): + load_env() + try: + conn = pg8000.connect(**CONFIG) + except Exception as e: + print('DB connect error:', e, file=sys.stderr) + sys.exit(2) + + result = {} + try: + with conn.cursor() as cur: + cur.execute(SQLS['risk_subject']) + subjects = fetch_all(cur) + cur.execute(SQLS['risk_permit']) + permits = fetch_all(cur) + cur.execute(SQLS['risk_sub_per']) + rels = fetch_all(cur) + conn.commit() + finally: + conn.close() + + # Build mapping: subject -> list of permit_ids + sub_to_permit_ids = {} + for r in rels: + sub_id = r['sub_id'] + per_id = r['per_id'] + sub_to_permit_ids.setdefault(sub_id, set()).add(per_id) + + # Subjects with aggregated permit_ids + subjects_out = [] + for s in subjects: + subjects_out.append({ + 'id': s['id'], + 'name': s['name'], + 'permit_ids': sorted(list(sub_to_permit_ids.get(s['id'], []))) + }) + + # Final JSON: keep full permit catalog (id+name), and subjects contain aggregated permit_ids + result['risk_subject'] = subjects_out + result['risk_permit'] = permits + + with open(OUTPUT, 'w', encoding='utf-8') as f: + json.dump(result, f, ensure_ascii=False, indent=2) + print('Exported to', OUTPUT) + +if __name__ == '__main__': + main() diff --git a/ingest_lawrisk.py b/ingest_lawrisk.py new file mode 100644 index 0000000..76f182f --- /dev/null +++ b/ingest_lawrisk.py @@ -0,0 +1,100 @@ +"""Ingest subjects and permit mappings into PostgreSQL. + +Reads risk_tables_export.json from repo root, embeds each subject name using +Aliyun DashScope embeddings (OpenAI-compatible), and stores into: + - law_sub(id TEXT PK, name TEXT, vector JSONB) + - law_sub_per(subject_id TEXT PK, permit_ids JSONB) + +Usage: + python ingest_lawrisk.py + +Ensure env var DASHSCOPE_API_KEY is set. +Optionally set PG_* vars (PG_HOST, PG_PORT, PG_USER, PG_PASSWORD, PG_DATABASE). +""" +from __future__ import annotations + +import json +import os +from typing import List + +from env_loader import load_env +from lawrisk_service import ( + ensure_database, + ensure_schema, + EmbeddingClient, + upsert_subjects, + upsert_subject_permits, + upsert_permits, +) + +REPO_JSON = os.getenv("LAWRISK_JSON", "risk_tables_export.json") + + +def main() -> None: + # Load .env first so service reads correct env + load_env() + ensure_database() + ensure_schema() + + with open(REPO_JSON, "r", encoding="utf-8") as f: + data = json.load(f) + + subjects = data.get("risk_subject", []) + permits = data.get("risk_permit", []) + # Prepare embeddings in small batches to avoid large payloads + client = EmbeddingClient() + + batched_rows = [] + BATCH = 32 + names: List[str] = [] + metas = [] # (id, name) + for s in subjects: + sid = s.get("id") + name = s.get("name") + if not sid or not name: + continue + names.append(name) + metas.append((sid, name)) + if len(names) >= BATCH: + vecs = client.embed_texts(names) + for (sid, name), vec in zip(metas, vecs): + batched_rows.append((sid, name, vec)) + names.clear() + metas.clear() + + if names: + vecs = client.embed_texts(names) + for (sid, name), vec in zip(metas, vecs): + batched_rows.append((sid, name, vec)) + + upsert_subjects(batched_rows) + + # Build subject->permit_ids mapping + per_rows = [] + for s in subjects: + sid = s.get("id") + pids = s.get("permit_ids", []) + if sid and isinstance(pids, list): + # ensure strings + per_rows.append((sid, [str(x) for x in pids])) + + if per_rows: + upsert_subject_permits(per_rows) + + # Upsert permit catalog (id -> name) + per_catalog = [] + for p in permits: + pid = p.get("id") + pname = p.get("name") + if pid and pname: + per_catalog.append((pid, pname)) + if per_catalog: + upsert_permits(per_catalog) + + print( + f"Ingested {len(batched_rows)} subjects, {len(per_rows)} subject-permit mappings, and {len(per_catalog)} permits into PostgreSQL." + ) + + +if __name__ == "__main__": + main() diff --git a/lawrisk_service.py b/lawrisk_service.py new file mode 100644 index 0000000..e4315f7 --- /dev/null +++ b/lawrisk_service.py @@ -0,0 +1,568 @@ +""" +LawRisk embedding retrieval service. + +Responsibilities: +- DB connection helpers (PostgreSQL via pg8000) +- Schema management (fs_law_risk.law_sub, fs_law_risk.law_sub_per) +- Embedding client (Aliyun DashScope OpenAI-compatible embeddings API) +- Chat client for LLM-based selection (Qwen via OpenAI-compatible /chat/completions) +- Search logic: embedding cosine or LLM subject selection + +Env vars used: +- PG_HOST, PG_PORT, PG_USER, PG_PASSWORD (PostgreSQL credentials) +- PG_DATABASE (defaults to fs_law_risk) +- PG_ADMIN_DB (defaults to postgres; used for CREATE DATABASE) +- DASHSCOPE_API_KEY (embedding API key) +- DASHSCOPE_BASE_URL (defaults to https://dashscope.aliyuncs.com/compatible-mode/v1) +- DASHSCOPE_EMBED_MODEL (defaults to text-embedding-v4) +- DASHSCOPE_EMBED_DIM (defaults to 1024) +- DASHSCOPE_CHAT_MODEL (defaults to qwen-plus-latest) +""" +from __future__ import annotations + +import json +import math +import os +import ssl +import urllib.request +import urllib.error +from typing import Any, Dict, Iterable, List, Optional, Tuple + +import pg8000.dbapi as pg + + +DEFAULT_DB = "fs_law_risk" +DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" +EMBED_MODEL = os.getenv("DASHSCOPE_EMBED_MODEL", "text-embedding-v4") +EMBED_DIM = int(os.getenv("DASHSCOPE_EMBED_DIM", "1024")) +EMBED_MAX_BATCH = max(1, int(os.getenv("DASHSCOPE_MAX_BATCH", "10"))) # DashScope limit <=10 +CHAT_MODEL = os.getenv("DASHSCOPE_CHAT_MODEL", "qwen-plus-latest") + +# Similarity thresholds (env configurable) +RETURN_IF_GE = float(os.getenv("LAWRISK_RETURN_IF_GE", "0.7")) +FALLBACK_GT = float(os.getenv("LAWRISK_FALLBACK_GT", "0.4")) +# Similarity thresholds (env configurable) +RETURN_IF_GE = float(os.getenv("LAWRISK_RETURN_IF_GE", "0.7")) +FALLBACK_GT = float(os.getenv("LAWRISK_FALLBACK_GT", "0.4")) + + +def _pg_conn(database: Optional[str] = None, autocommit: bool = False) -> pg.Connection: + host = os.getenv("PG_HOST", "8.138.196.105") + port = int(os.getenv("PG_PORT", "5432")) + user = os.getenv("PG_USER", "postgres") + password = os.getenv("PG_PASSWORD", "difyai123456") + dbname = database or os.getenv("PG_DATABASE", DEFAULT_DB) + conn = pg.connect(host=host, port=port, user=user, password=password, database=dbname) + conn.autocommit = autocommit + return conn + + +def ensure_database(dbname: str = DEFAULT_DB) -> None: + # Create database if not exists by connecting to postgres + admin_db = os.getenv("PG_ADMIN_DB", "postgres") + with _pg_conn(database=admin_db, autocommit=True) as c: + cur = c.cursor() + cur.execute("SELECT 1 FROM pg_database WHERE datname=%s", (dbname,)) + if cur.fetchone() is None: + cur.execute(f"CREATE DATABASE {dbname}") + + +def ensure_schema() -> None: + with _pg_conn() as c: + cur = c.cursor() + # Store vectors and permit ids as JSONB for portability + cur.execute( + """ + CREATE TABLE IF NOT EXISTS law_sub ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + vector JSONB NOT NULL + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS law_sub_per ( + subject_id TEXT PRIMARY KEY, + permit_ids JSONB NOT NULL + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS law_permit ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL + ) + """ + ) + c.commit() + + +class EmbeddingClient: + def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None): + self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY") + self.base_url = base_url or os.getenv("DASHSCOPE_BASE_URL", DEFAULT_BASE_URL) + if not self.api_key: + raise RuntimeError("DASHSCOPE_API_KEY is not set") + + def embed_texts(self, texts: List[str]) -> List[List[float]]: + # sanitize inputs + clean_inputs = [str(t) for t in texts if isinstance(t, str) and str(t).strip()] + if not clean_inputs: + raise ValueError("No valid input texts for embeddings") + + # chunk by provider batch limit and concatenate results to preserve order + out: List[List[float]] = [] + for i in range(0, len(clean_inputs), EMBED_MAX_BATCH): + chunk = clean_inputs[i : i + EMBED_MAX_BATCH] + out.extend(self._embed_batch(chunk)) + if len(out) != len(clean_inputs): + raise RuntimeError( + f"Embedding API returned unexpected result count: got {len(out)}, want {len(clean_inputs)}" + ) + return out + + def _embed_batch(self, texts: List[str]) -> List[List[float]]: + url = self.base_url.rstrip("/") + "/embeddings" + body = { + "model": EMBED_MODEL, + "input": texts, + "dimensions": EMBED_DIM, + "encoding_format": "float", + } + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Authorization", f"Bearer {self.api_key}") + req.add_header("Content-Type", "application/json") + ctx = ssl.create_default_context() + try: + with urllib.request.urlopen(req, context=ctx, timeout=30) as resp: + raw = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8", errors="replace") if hasattr(e, 'read') else "" + raise RuntimeError( + f"Embedding API error {e.code}: {err_body or e.reason} | sent={json.dumps(body, ensure_ascii=False)[:500]}" + ) from e + payload = json.loads(raw) + out: List[List[float]] = [] + for item in payload.get("data", []): + emb = item.get("embedding") + if isinstance(emb, list): + out.append([float(x) for x in emb]) + return out + + def embed_one(self, text: str) -> List[float]: + return self.embed_texts([text])[0] + + +class ChatClient: + def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None): + self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY") + self.base_url = base_url or os.getenv("DASHSCOPE_BASE_URL", DEFAULT_BASE_URL) + if not self.api_key: + raise RuntimeError("DASHSCOPE_API_KEY is not set") + + def chat(self, messages: List[Dict[str, str]], model: Optional[str] = None, temperature: float = 0.2) -> str: + url = self.base_url.rstrip("/") + "/chat/completions" + body = { + "model": model or CHAT_MODEL, + "messages": messages, + "temperature": temperature, + } + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Authorization", f"Bearer {self.api_key}") + req.add_header("Content-Type", "application/json") + ctx = ssl.create_default_context() + try: + with urllib.request.urlopen(req, context=ctx, timeout=60) as resp: + raw = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8", errors="replace") if hasattr(e, 'read') else "" + raise RuntimeError( + f"Chat API error {e.code}: {err_body or e.reason}" + ) from e + payload = json.loads(raw) + choices = payload.get("choices", []) + if not choices: + raise RuntimeError("Chat API returned no choices") + msg = choices[0].get("message", {}) + content = msg.get("content", "") + return str(content) + + +def generate_question_suggestions(query: str, max_q: int = 5) -> List[str]: + """Legacy LLM-based suggestion generator (kept for reference). Not used by default.""" + try: + chat = ChatClient() + content = chat.chat([ + {"role": "system", "content": "你是政务事项问答助手。请输出与主题事项高度相关的精简推荐问题,仅输出 JSON 数组。"}, + {"role": "user", "content": f"请针对: {query} 给出不超过 {max_q} 条中文推荐问题,仅输出 JSON 数组。"}, + ], model=CHAT_MODEL, temperature=0.3) + txt = content.strip() + start = txt.find("[") + end = txt.rfind("]") + arr = json.loads(txt[start : end + 1] if start != -1 and end != -1 and end > start else txt) + out: List[str] = [] + if isinstance(arr, list): + for x in arr: + if isinstance(x, str) and x.strip(): + out.append(x.strip()) + return out[:max_q] + except Exception: + return [] + + +def _normalize_text(s: str) -> str: + return "".join(ch for ch in str(s) if ch.isalnum()) + + +def shortlist_subjects(query: str, k: int = 5) -> List[Tuple[str, str]]: + """Return up to k subjects with highest lexical overlap to query. + + Simple char-level overlap score to keep it deterministic and fast. + """ + q = set(_normalize_text(query)) + if not q: + q = set(query) + with _pg_conn() as c: + cur = c.cursor() + cur.execute("SELECT id, name FROM law_sub") + subs = [(str(sid), str(name)) for sid, name in cur.fetchall()] + scored: List[Tuple[float, Tuple[str, str]]] = [] + for sid, name in subs: + n = set(_normalize_text(name)) or set(name) + inter = len(q & n) + denom = max(1, len(n)) + score = inter / denom + if inter > 0: + scored.append((score, (sid, name))) + scored.sort(key=lambda x: x[0], reverse=True) + return [row for _s, row in scored[:k]] + + +def suggest_questions_from_subjects(subject_names: List[str], max_q: int = 5) -> List[str]: + """Return subject names directly (no extra wording).""" + out: List[str] = [] + for nm in subject_names: + nm = (nm or "").strip() + if nm and nm not in out: + out.append(nm) + if len(out) >= max_q: + break + return out + + +def suggest_questions_embed(query: str, max_q: int = 5) -> List[str]: + """Use embeddings to pick top-N subject names (no added text).""" + try: + client = EmbeddingClient() + qvec = client.embed_one(query) + except Exception: + # Embedding not available; fallback to lexical shortlist + subs = shortlist_subjects(query, max(1, max_q)) + return [name for _sid, name in subs][:max_q] + + # Load subjects with vectors + with _pg_conn() as c: + cur = c.cursor() + cur.execute("SELECT id, name, vector FROM law_sub") + subjects: List[Tuple[str, str, List[float]]] = [] + for sid, name, vec_json in cur.fetchall(): + if isinstance(vec_json, str): + try: + vec = json.loads(vec_json) + except Exception: + vec = [] + else: + vec = vec_json + if isinstance(vec, list) and vec: + subjects.append((str(sid), str(name), [float(x) for x in vec])) + + if not subjects: + subs = shortlist_subjects(query, max(1, max_q)) + return [name for _sid, name in subs][:max_q] + + scored: List[Tuple[float, Tuple[str, str]]] = [] + for sid, name, vec in subjects: + s = _cosine(qvec, vec) + scored.append((s, (sid, name))) + scored.sort(key=lambda x: x[0], reverse=True) + + # Take top subjects (more than max_q to allow templating to fill up to max_q) + top_subjects = [nm for _score, (_sid, nm) in scored[: max_q]] + return top_subjects + +def _cosine(a: List[float], b: List[float]) -> float: + if not a or not b or len(a) != len(b): + return 0.0 + dot = 0.0 + na = 0.0 + nb = 0.0 + for x, y in zip(a, b): + dot += x * y + na += x * x + nb += y * y + if na == 0.0 or nb == 0.0: + return 0.0 + return dot / math.sqrt(na * nb) + + +def upsert_subjects( + rows: Iterable[Tuple[str, str, List[float]]] +) -> None: + """Upsert subjects into law_sub.""" + with _pg_conn() as c: + cur = c.cursor() + for sid, name, vec in rows: + cur.execute( + """ + INSERT INTO law_sub (id, name, vector) + VALUES (%s, %s, %s::jsonb) + ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, vector=EXCLUDED.vector + """, + (sid, name, json.dumps(vec)), + ) + c.commit() + + +def upsert_subject_permits(rows: Iterable[Tuple[str, List[str]]]) -> None: + with _pg_conn() as c: + cur = c.cursor() + for sid, permit_ids in rows: + cur.execute( + """ + INSERT INTO law_sub_per (subject_id, permit_ids) + VALUES (%s, %s::jsonb) + ON CONFLICT (subject_id) DO UPDATE SET permit_ids=EXCLUDED.permit_ids + """, + (sid, json.dumps(permit_ids)), + ) + c.commit() + + +def upsert_permits(rows: Iterable[Tuple[str, str]]) -> None: + """Upsert permit catalog into law_permit (id -> name).""" + with _pg_conn() as c: + cur = c.cursor() + for pid, name in rows: + cur.execute( + """ + INSERT INTO law_permit (id, name) + VALUES (%s, %s) + ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name + """, + (pid, name), + ) + c.commit() + + +def search_subjects(query: str, return_debug: bool = False, top_k_debug: int = 5) -> Dict[str, Any]: + """Search by embedding similarity, return JSON object compliant with PRD. + + Thresholds: + - return all with score >= 0.5 + - if none >= 0.5 but max > 0.4, return the single best one + """ + client = EmbeddingClient() + qvec = client.embed_one(query) + + # load all subjects + with _pg_conn() as c: + cur = c.cursor() + cur.execute("SELECT id, name, vector FROM law_sub") + subs: List[Tuple[str, str, List[float]]] = [] + for sid, name, vec_json in cur.fetchall(): + # vec_json may come back as Python list or JSON string depending on driver version + if isinstance(vec_json, str): + try: + vec = json.loads(vec_json) + except Exception: + vec = [] + else: + vec = vec_json + subs.append((str(sid), str(name), list(vec) if isinstance(vec, list) else [])) + + # Build permit lookup + cur.execute("SELECT subject_id, permit_ids FROM law_sub_per") + per_map: Dict[str, List[str]] = {} + for sid, pids in cur.fetchall(): + # pids may be list or JSON string + if isinstance(pids, str): + try: + p_list = json.loads(pids) + except Exception: + p_list = [] + else: + p_list = list(pids) if isinstance(pids, list) else [] + per_map[str(sid)] = [str(x) for x in p_list] + + scored: List[Tuple[float, Tuple[str, str, List[float]]]] = [] + for row in subs: + score = _cosine(qvec, row[2]) + scored.append((score, row)) + scored.sort(key=lambda x: x[0], reverse=True) + + # Build permit name lookup + permit_name: Dict[str, str] = {} + try: + with _pg_conn() as c2: + cur2 = c2.cursor() + cur2.execute("SELECT id, name FROM law_permit") + for pid, pname in cur2.fetchall(): + permit_name[str(pid)] = str(pname) + except Exception: + # If table missing or query fails, leave map empty; upstream should seed via ingest + permit_name = {} + + results: List[Dict[str, Any]] = [] + for score, (sid, name, _vec) in scored: + if score >= RETURN_IF_GE: + item = { + "id": sid, + "name": name, + # Build permit map: name -> id + "permit": {permit_name.get(pid, ""): pid for pid in per_map.get(sid, []) if permit_name.get(pid)}, + } + if return_debug: + item["score"] = round(float(score), 6) + results.append(item) + + if not results and scored and scored[0][0] > FALLBACK_GT: + sid, name, _ = scored[0][1] + best_score = scored[0][0] + item = { + "id": sid, + "name": name, + "permit": {permit_name.get(pid, ""): pid for pid in per_map.get(sid, []) if permit_name.get(pid)}, + } + if return_debug: + item["score"] = round(float(best_score), 6) + results = [item] + + out: Dict[str, Any] = {"risk_subject": results} + if return_debug: + decision = ( + "returned_ge_threshold" if results + else "returned_top_fallback" if (scored and scored[0][0] > FALLBACK_GT) + else "no_match_below_fallback" + ) + top_list = [] + for s, (sid, name, _v) in scored[: max(0, top_k_debug) or 5]: + top_list.append({"id": sid, "name": name, "score": round(float(s), 6)}) + out["debug"] = { + "query": query, + "qvec_dim": len(qvec), + "thresholds": {"return_if_ge": RETURN_IF_GE, "fallback_gt": FALLBACK_GT}, + "num_subjects": len(subs), + "max_score": round(float(scored[0][0]), 6) if scored else 0.0, + "top_candidates": top_list, + "decision": decision, + } + return out + + +def search_subjects_llm(query: str, return_debug: bool = False, top_k_debug: int = 5) -> Dict[str, Any]: + """Use LLM to pick one or more subject IDs from the catalog by instruction. + + Steps: + - Load subject id+name list from DB + - Ask LLM (Qwen) to select at least one subject id from the list that best matches the user query + - Map selected ids to full entries (name+permit_ids) and return + """ + # Load catalog + with _pg_conn() as c: + cur = c.cursor() + cur.execute("SELECT id, name FROM law_sub") + subjects = [(str(sid), str(name)) for sid, name in cur.fetchall()] + cur.execute("SELECT subject_id, permit_ids FROM law_sub_per") + per_map: Dict[str, List[str]] = {} + for sid, pids in cur.fetchall(): + if isinstance(pids, str): + try: + p_list = json.loads(pids) + except Exception: + p_list = [] + else: + p_list = list(pids) if isinstance(pids, list) else [] + per_map[str(sid)] = [str(x) for x in p_list] + + # Build concise subject list block: id | name per line + # Keep within reasonable token limits; if too long, truncate and rely on LLM suggestion quality. + lines = [f"{sid}\t{name}" for sid, name in subjects] + subjects_block = "\n".join(lines) + + system_msg = ( + "你是政务事项检索助手。根据用户的中文查询,从给定的主题事项清单中选择最相关的主题事项。" + "只允许从清单中选择,不能编造。若没有足够相关的主题,请返回空数组 []." + "始终以 JSON 数组返回所选主题事项的 id 列表,例如: [\"id1\", \"id2\"]." + ) + user_msg = ( + f"用户问题: {query}\n\n" + f"主题事项清单(格式: id名称):\n{subjects_block}\n\n" + "请仅输出 JSON 数组 (仅数组本身)。若无匹配请输出 []." + ) + + chat = ChatClient() + content = chat.chat([ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], model=CHAT_MODEL, temperature=0.2) + + # Try parsing as JSON array of strings; robustly extract if wrapped text exists + selected_ids: List[str] = [] + try: + txt = content.strip() + start = txt.find("[") + end = txt.rfind("]") + if start != -1 and end != -1 and end > start: + arr = json.loads(txt[start : end + 1]) + else: + arr = json.loads(txt) + if isinstance(arr, list): + for x in arr: + if isinstance(x, str): + selected_ids.append(x) + elif isinstance(x, dict) and "id" in x and isinstance(x["id"], str): + selected_ids.append(x["id"]) + except Exception: + selected_ids = [] + + # Deduplicate and keep only ids that exist + id_set = {sid for sid, _ in subjects} + chosen = [] + for sid in selected_ids: + if sid in id_set and sid not in chosen: + chosen.append(sid) + # Allow empty result when nothing is relevant + + # Load permit names + permit_name: Dict[str, str] = {} + try: + with _pg_conn() as c2: + cur2 = c2.cursor() + cur2.execute("SELECT id, name FROM law_permit") + for pid, pname in cur2.fetchall(): + permit_name[str(pid)] = str(pname) + except Exception: + permit_name = {} + + results = [] + name_map = {sid: name for sid, name in subjects} + for sid in chosen: + results.append({ + "id": sid, + "name": name_map.get(sid, ""), + "permit": {permit_name.get(pid, ""): pid for pid in per_map.get(sid, []) if permit_name.get(pid)}, + }) + + out: Dict[str, Any] = {"risk_subject": results} + if return_debug: + out["debug"] = { + "model": CHAT_MODEL, + "num_subjects": len(subjects), + "selected_ids": chosen, + "allow_empty": True, + } + return out diff --git a/risk_tables_export.json b/risk_tables_export.json new file mode 100644 index 0000000..61d1330 --- /dev/null +++ b/risk_tables_export.json @@ -0,0 +1,1130 @@ +{ + "risk_subject": [ + { + "id": "edfbb171-9a45-4c57-b8e3-34adfd78ed85", + "name": "报关单位备案(进出口货物收发货人、报关企业)", + "permit_ids": [ + "29254acf77f93913a96e577bf1c7894d", + "6a357c0aaa56458d6da64d90c08ffaaa", + "b40fc5ef86fdb6f3a8456a691a762696" + ] + }, + { + "id": "992da9ff-e4ad-4298-9989-9e2266891cf4", + "name": "出口食品", + "permit_ids": [ + "6a357c0aaa56458d6da64d90c08ffaaa" + ] + }, + { + "id": "4c18f6576e842d5087796807f005e8bd", + "name": "开办包装装潢印刷企业(内资)", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693", + "ba00f4d2e916d969c6ee0ac1babbd728", + "c78f43f131ad63bd21822ddc2e742850" + ] + }, + { + "id": "bd8201633df470633bdbb944d8f7916f", + "name": "开办便利店", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "3c4b5347a4476d891214a34811a5d0db", + "509b2872fc7c38c08f252a2b426fd49f", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "709b4718d72229311066e529650b8abf", + "71655107646f3e1cf09e9be42f8f431e", + "8a130133-b3c4-44c6-ad80-c1c201da15d8", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "9b8c389a0d49508c78a7345ec4fb02f2" + ] + }, + { + "id": "70df6bc48c7b1f191610d82381edd95a", + "name": "开办病媒生物预防控制有偿服务企业", + "permit_ids": [] + }, + { + "id": "0e69c1812b01592ad3db2c6d6a631ff0", + "name": "开办超市", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "3c4b5347a4476d891214a34811a5d0db", + "509b2872fc7c38c08f252a2b426fd49f", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "709b4718d72229311066e529650b8abf", + "71655107646f3e1cf09e9be42f8f431e", + "8a130133-b3c4-44c6-ad80-c1c201da15d8", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "9b8c389a0d49508c78a7345ec4fb02f2", + "fa2f3e05c92297be096b63e25d30bfbe" + ] + }, + { + "id": "e81115c82dcdf917eba748a660758989", + "name": "开办出版零售企业(非书店)", + "permit_ids": [ + "01601e30790dd10206bd5d33a86b483d", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "f1f4a486b80c9efc7854c4712869e291", + "name": "开办出版物批发企业", + "permit_ids": [ + "0f63bc97bd44ae34074a8eb80a7e6d0d", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "384aeb24a23e913268aad33354f705e7", + "name": "开办电影院", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "509b2872fc7c38c08f252a2b426fd49f", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "a0572119839422e1d11ee8801d6c58b7", + "fa2f3e05c92297be096b63e25d30bfbe" + ] + }, + { + "id": "092d25a8340919207b539f7d4183f64c", + "name": "开办动物隔离、无害化处理企业", + "permit_ids": [ + "88761ee3fdac02c5d29508adc6e54e05", + "8d49de002f24d37fcf3663574723e693" + ] + }, + { + "id": "27c67f5642734bec43068f298c7cd3fa", + "name": "开办动物诊所", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693", + "9cd43d6cd00db09f4c0dcb73eccdb6af" + ] + }, + { + "id": "b558ec9cb9512fd41047cd0a3988c3c8", + "name": "开办二类医疗器械销售", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "71655107646f3e1cf09e9be42f8f431e", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "8a46ec7f67e50098e5973550a46d22b2", + "name": "开办饭店", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "16e96bf1-fb4f-42bf-98e9-5307f154b513", + "709b4718d72229311066e529650b8abf", + "8a130133-b3c4-44c6-ad80-c1c201da15d8", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "9b8c389a0d49508c78a7345ec4fb02f2" + ] + }, + { + "id": "961299e3879ca93101c26df1804006ea", + "name": "开办房地产经纪机构", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693", + "ce3d1528c441a4638a69c8731eb8f780" + ] + }, + { + "id": "92151a5bf64c0738b4bd22c6115dab78", + "name": "开办高端装备制造企业", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693", + "c78f43f131ad63bd21822ddc2e742850" + ] + }, + { + "id": "51b70ec786a87928da0d232e2d46e34b", + "name": "开办歌舞娱乐场所", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "16e96bf1-fb4f-42bf-98e9-5307f154b513", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "baaf120e02ad1265f2ab0a59e82bb4ee", + "f770843f4a910bae9767e81956961713", + "fa2f3e05c92297be096b63e25d30bfbe" + ] + }, + { + "id": "ba4e253fd02bbd9aefe1104c5fffa8cf", + "name": "开办公路旅客班车运输企业", + "permit_ids": [] + }, + { + "id": "b94f7d20dc9dbe1e56e1eac26fbdfc93", + "name": "开办公路旅客包车运输企业", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693", + "fdf74943b5b3c46ae828a48913fde8af" + ] + }, + { + "id": "6133e22af6e29d5f0c2b8470744c3da4", + "name": "开办烘焙坊面包房", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "787d394080c907900958001726f65146", + "name": "开办货运公司", + "permit_ids": [ + "33280c3ebdd72bec6760bfbfe4a7cb29", + "8d49de002f24d37fcf3663574723e693", + "8dea1f04ecbe80d1b01e5eade81f0453" + ] + }, + { + "id": "beb115b8821d589ca599a0edf9938c18", + "name": "开办健身房", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "509b2872fc7c38c08f252a2b426fd49f", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "6a6ca32900a7959129c8080d304b5a8b", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "8e64f11b3a5297dfb97566b23cebc89b", + "name": "开办进出口贸易企业", + "permit_ids": [ + "42717417fb691ccdb988f966fb3dcc0b", + "8d49de002f24d37fcf3663574723e693" + ] + }, + { + "id": "779ff0e115988ac273ef69c13046f3a4", + "name": "开办酒吧", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "709b4718d72229311066e529650b8abf", + "8a130133-b3c4-44c6-ad80-c1c201da15d8", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "9b8c389a0d49508c78a7345ec4fb02f2" + ] + }, + { + "id": "c49061441e3ed6a4e12fe0b41a5702aa", + "name": "开办咖啡馆茶馆", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "709b4718d72229311066e529650b8abf", + "8a130133-b3c4-44c6-ad80-c1c201da15d8", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "9b8c389a0d49508c78a7345ec4fb02f2" + ] + }, + { + "id": "0e2c8fbf14fdd295eabc04fb3f09094e", + "name": "开办卡丁车游乐场", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "86386c8aec80a19e3f2079754906ad8f", + "name": "开办林产品采集企业", + "permit_ids": [] + }, + { + "id": "c761acf3521b1b90cf91b442fb0b46db", + "name": "开办林木育种和育苗企业", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693", + "id7h4ad89993ba572db7b818ec85ce5e" + ] + }, + { + "id": "681e706bc77c4f43f89e9bd86155e688", + "name": "开办轮滑馆", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "3b62fb82ac19bfe90d3e1e3434597eb4", + "name": "开办旅馆", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "509b2872fc7c38c08f252a2b426fd49f", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "6a6ca32900a7959129c8080d304b5a8b", + "709b4718d72229311066e529650b8abf", + "8a130133-b3c4-44c6-ad80-c1c201da15d8", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "993e8ee7c5bb6cca27d6e4b50417c396", + "9b8c389a0d49508c78a7345ec4fb02f2", + "baaf120e02ad1265f2ab0a59e82bb4ee", + "c78f43f131ad63bd21822ddc2e742850", + "f770843f4a910bae9767e81956961713", + "fa2f3e05c92297be096b63e25d30bfbe" + ] + }, + { + "id": "6b881e4207ab57c51a2e43a4fc303051", + "name": "开办旅行社", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693" + ] + }, + { + "id": "30e073eb2b1e992f56fc07e60a674f76", + "name": "开办旅行社分社", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693" + ] + }, + { + "id": "e1fd1ae615a7cc7f94668fffcab0301c", + "name": "开办美容美发店", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "fa2f3e05c92297be096b63e25d30bfbe" + ] + }, + { + "id": "5731f5eee8259e5c10d0a1d1868e00ba", + "name": "开办美术馆", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "fa2f3e05c92297be096b63e25d30bfbe" + ] + }, + { + "id": "5fbec896221e5564d5211713ddfa19f5", + "name": "开办母婴用品", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "509b2872fc7c38c08f252a2b426fd49f", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "709b4718d72229311066e529650b8abf", + "71655107646f3e1cf09e9be42f8f431e", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "c333eb7a24dc69776b798ee884dd0990", + "name": "开办农副食品加工企业", + "permit_ids": [ + "2b12555dfcb8d4c00d32d9170f705800", + "8d49de002f24d37fcf3663574723e693", + "c78f43f131ad63bd21822ddc2e742850" + ] + }, + { + "id": "4ebcaa9d210921673268396b87b5e4fb", + "name": "开办农药店", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693", + "f4b54f92ac348d2e082c17e5e3968865" + ] + }, + { + "id": "81a4d5990bbb56d735c339fcfedd8759", + "name": "开办攀岩馆", + "permit_ids": [ + "6a6ca32900a7959129c8080d304b5a8b", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "0123dd7c1174065f506ea18ef11cd05e", + "name": "开办其他印刷品印刷企业(内资)", + "permit_ids": [ + "40650656b4dc2bc58ce7b75b6a76cf7e", + "8d49de002f24d37fcf3663574723e693", + "c78f43f131ad63bd21822ddc2e742850" + ] + }, + { + "id": "3f2a212f68f45cb4bc0a6bb1e217099f", + "name": "开办潜水馆", + "permit_ids": [ + "6a6ca32900a7959129c8080d304b5a8b", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "4457f561945749e6c13437d52cd8ed4e", + "name": "开办燃气器具维修企业", + "permit_ids": [ + "6072dd6afb3260e6e86f32c7dafb739e", + "8d49de002f24d37fcf3663574723e693" + ] + }, + { + "id": "9dfdcfd27e4abdb8b55c72d341a13eb8", + "name": "开办人力资源中介机构", + "permit_ids": [ + "2c797b336e693c5aa5cee07a67cfd3a1", + "8d49de002f24d37fcf3663574723e693" + ] + }, + { + "id": "10a7c8848847f631dca47fe72c1efcb5", + "name": "开办三类医疗器械销售企业", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "87bb223cc9b6e35bb0832fdeae5278bb", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "916b978803bad257b188ddc50415b1da", + "name": "开办射击馆", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "4a056c16c26b2205eef65a661f627b45", + "name": "开办射箭馆", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "48ae32d09b8e93e378154a5c8751dba2", + "name": "开办食品生产企业", + "permit_ids": [ + "2b12555dfcb8d4c00d32d9170f705800", + "8d49de002f24d37fcf3663574723e693", + "c78f43f131ad63bd21822ddc2e742850" + ] + }, + { + "id": "60bca7b416c1493dfafdfbc0cd018fcb", + "name": "开办食品小作坊", + "permit_ids": [ + "0e774a1157608ff22d3be62c2441d027" + ] + }, + { + "id": "2c7bcffb01f88b6a9ebcd5d8fac6e7a0", + "name": "开办兽药店", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "9cf4a6d006a16ade6bae428cf89f4864" + ] + }, + { + "id": "ba0c56041230e6276d9d29f55825480f", + "name": "开办书店", + "permit_ids": [ + "01601e30790dd10206bd5d33a86b483d", + "04bfa019634ca1aa0b9f7c783fd85dce", + "509b2872fc7c38c08f252a2b426fd49f", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "fa2f3e05c92297be096b63e25d30bfbe" + ] + }, + { + "id": "5d4cf21070e329d7b571fecfad64db5b", + "name": "开办水产苗种生产企业", + "permit_ids": [ + "45eddd7ff9d0b85143a8ca2fda1d39eb", + "71203b16487e96e8ee2f073d5df764ac", + "8d49de002f24d37fcf3663574723e693", + "a34b0d8ab2378a222ab940106a45c2f1" + ] + }, + { + "id": "b8387d55a286ed84e83bc5511375c25d", + "name": "开办网吧", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "509b2872fc7c38c08f252a2b426fd49f", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "709b4718d72229311066e529650b8abf", + "7c47f77fc3203e4bd946b16831e50ac7", + "8a130133-b3c4-44c6-ad80-c1c201da15d8", + "8a599850d018391d64df36b53cf570d8", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "9b8c389a0d49508c78a7345ec4fb02f2" + ] + }, + { + "id": "faea03f050b698587bc1bc5032f54c22", + "name": "开办洗浴场所", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "509b2872fc7c38c08f252a2b426fd49f", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "571426f8-2099-4745-a623-73fab88be54c", + "6a6ca32900a7959129c8080d304b5a8b", + "709b4718d72229311066e529650b8abf", + "8a130133-b3c4-44c6-ad80-c1c201da15d8", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "9b8c389a0d49508c78a7345ec4fb02f2", + "baaf120e02ad1265f2ab0a59e82bb4ee", + "f770843f4a910bae9767e81956961713", + "fa2f3e05c92297be096b63e25d30bfbe" + ] + }, + { + "id": "45faa8d76b1e4cc2b478994d01f85d93", + "name": "开办小餐饮", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "3da84a19d3867c1647e004007c6806e7", + "name": "开办药店", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "3c4b5347a4476d891214a34811a5d0db", + "509b2872fc7c38c08f252a2b426fd49f", + "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "62aa6370ccd984684cb404de46bf08dc", + "709b4718d72229311066e529650b8abf", + "71655107646f3e1cf09e9be42f8f431e", + "87bb223cc9b6e35bb0832fdeae5278bb", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "0dde70197c3590c8f5b36a1dbc045de5", + "name": "开办音像制品制作企业", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693", + "f57ae235446057300fd1aaaa52bb3063" + ] + }, + { + "id": "4ea12f89ddc2ff75f6ba4b739236caf8", + "name": "开办饮料制造企业", + "permit_ids": [ + "2b12555dfcb8d4c00d32d9170f705800", + "8d49de002f24d37fcf3663574723e693", + "c78f43f131ad63bd21822ddc2e742850" + ] + }, + { + "id": "b34a1fe3487a9762de79beaac9a828e7", + "name": "开办游泳馆", + "permit_ids": [ + "6a6ca32900a7959129c8080d304b5a8b", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "fa2f3e05c92297be096b63e25d30bfbe" + ] + }, + { + "id": "838adf5da7cd0d01465fcde53bbc0f68", + "name": "开办渔业捕捞企业", + "permit_ids": [] + }, + { + "id": "b4c9e672063a821c1300f423b3ec5354", + "name": "开办月子中心", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + }, + { + "id": "676ed4f01b5a6b39bcca5323e2f01457", + "name": "开办展厅", + "permit_ids": [ + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e", + "fa2f3e05c92297be096b63e25d30bfbe" + ] + }, + { + "id": "f486b82e495991c642c8c611420d1b9a", + "name": "开办种子批发企业", + "permit_ids": [ + "0497f3a50dc2ce26aeb03b5a833ba286", + "8d49de002f24d37fcf3663574723e693" + ] + }, + { + "id": "23e1b41a37e0f8b6deb23d27cee492f8", + "name": "开办种子生产经营企业", + "permit_ids": [ + "8d49de002f24d37fcf3663574723e693", + "id7h4ad89993ba572db7b818ec85ce5e" + ] + }, + { + "id": "23c239f0-6202-4624-bba7-4a325411a0c5", + "name": "粮食收购备案", + "permit_ids": [ + "1c536a650ff1e35fd97eadc45c6b9037" + ] + }, + { + "id": "772dfe6a80c30be32b6c19692c9d027e", + "name": "现场制售食品", + "permit_ids": [ + "04bfa019634ca1aa0b9f7c783fd85dce", + "709b4718d72229311066e529650b8abf", + "8d49de002f24d37fcf3663574723e693", + "8f7c8c613adfbd815a78c1e60ec4330e" + ] + } + ], + "risk_permit": [ + { + "id": "e08615c5f7f7cc0683a68b783f033dc8", + "name": "《市场准入负面清单》禁止准入类" + }, + { + "id": "9ba5462e3aebb61e52600c835ebccda8", + "name": "《卫星地面接收设施安装服务许可证》(新证)审批" + }, + { + "id": "8d49de002f24d37fcf3663574723e693", + "name": "办理营业执照" + }, + { + "id": "b40fc5ef86fdb6f3a8456a691a762696", + "name": "报关单位备案(报关企业)" + }, + { + "id": "29254acf77f93913a96e577bf1c7894d", + "name": "报关单位备案(进出口货物收发货人)" + }, + { + "id": "b0a658209830fdd641ce2c84a5c4116c", + "name": "采矿权新立、延续、变更登记发证与注销登记" + }, + { + "id": "107e58b61eaff71976cd93b36a69f1c3", + "name": "蚕种生产经营许可证核发" + }, + { + "id": "22ad3183-9bbe-4899-8f73-0c54f8fcfac1", + "name": "测试2222" + }, + { + "id": "13d2989c6fcae6076334e00e23e30c3d", + "name": "承印加工境外包装装潢和其他印刷品备案核准" + }, + { + "id": "d850d56026631b03eb3c60dc5ca22b4b", + "name": "城市建筑垃圾处置(排放)核准" + }, + { + "id": "51e4b83be3d1710f05bbeea9ac30af15", + "name": "出版物发行单位在批准的经营范围内通过互联网等信息网络从事出版物发行业务的备案" + }, + { + "id": "af95a43c817a544f62ba803677807588", + "name": "出版物批发、零售单位设立不具备法人资格的分支机构,或者出版单位设立发行本版出版物的不具备法人资格的发行分支机构的备案" + }, + { + "id": "6a357c0aaa56458d6da64d90c08ffaaa", + "name": "出口食品生产企业备案" + }, + { + "id": "ba00f4d2e916d969c6ee0ac1babbd728", + "name": "从事包装装潢印刷品印刷经营活动及其变更事项审批" + }, + { + "id": "d1e72e9bfaae035cf85024d09d928c36", + "name": "从事城市生活垃圾经营性清扫、收集、运输服务审批" + }, + { + "id": "01601e30790dd10206bd5d33a86b483d", + "name": "从事出版物零售业务许可(含音像制品、电子出版物)" + }, + { + "id": "40650656b4dc2bc58ce7b75b6a76cf7e", + "name": "从事其他印刷品印刷经营活动及其变更事项审批" + }, + { + "id": "fdf74943b5b3c46ae828a48913fde8af", + "name": "从事县内道路旅客运输包车经营许可" + }, + { + "id": "535ff7403ed8e59424968ca974a3093b", + "name": "从事专项排版、制版、装订印刷经营活动及其变更事项审批" + }, + { + "id": "80f4e6fc2057395498289acbcd2d649e", + "name": "道路旅客运输站(场)经营许可" + }, + { + "id": "8736b997ab7d97d605d5382f0f357c36", + "name": "第二、三类非药品类易制毒化学品生产、经营备案" + }, + { + "id": "62aa6370ccd984684cb404de46bf08dc", + "name": "第二类精神药品零售业务审批" + }, + { + "id": "71655107646f3e1cf09e9be42f8f431e", + "name": "第二类医疗器械经营备案" + }, + { + "id": "87bb223cc9b6e35bb0832fdeae5278bb", + "name": "第三类医疗器械经营许可" + }, + { + "id": "ffa0108bf5dfeb07238b8d65618cd2ed", + "name": "电视剧制作许可证(乙种)申请" + }, + { + "id": "a0572119839422e1d11ee8801d6c58b7", + "name": "电影放映单位设立审批" + }, + { + "id": "88761ee3fdac02c5d29508adc6e54e05", + "name": "动物防疫条件合格证核发" + }, + { + "id": "9cd43d6cd00db09f4c0dcb73eccdb6af", + "name": "动物诊疗许可证核发" + }, + { + "id": "ce3d1528c441a4638a69c8731eb8f780", + "name": "房地产经纪机构及其分支机构设立备案" + }, + { + "id": "bb0ca73af2f17834033d9951f9cd9313", + "name": "放射诊疗许可" + }, + { + "id": "8bf90867d9bc9e0aab7d42e0f025a585", + "name": "废弃电器电子产品处理企业资格审批" + }, + { + "id": "2b4d37edff785c7e32fcc423d9adec7a", + "name": "蜂种生产经营许可证核发" + }, + { + "id": "5fa63ad6fd33047096355a6a9a5f323c", + "name": "港口经营许可" + }, + { + "id": "baaf120e02ad1265f2ab0a59e82bb4ee", + "name": "歌舞娱乐场所从事娱乐场所经营活动审批" + }, + { + "id": "1adbd5aa-8a5c-401f-9c55-6e55770cd894", + "name": "个人征信机构设立分支机构、合并或者分立、变更注册资本、变更出资额审批" + }, + { + "id": "fa2f3e05c92297be096b63e25d30bfbe", + "name": "公共场所卫生许可" + }, + { + "id": "28176371adbc001ec289efa00fd54873", + "name": "公章刻制业特种行业许可证核发" + }, + { + "id": "709b4718d72229311066e529650b8abf", + "name": "公众聚集场所投入使用、营业前消防安全检查(非告知承诺件)" + }, + { + "id": "8f7c8c613adfbd815a78c1e60ec4330e", + "name": "公众聚集场所投入使用、营业前消防安全检查(告知承诺件)" + }, + { + "id": "1ad8130f119fa30c82cdc6d88b5bd642", + "name": "广播电视节目制作经营许可证(新证)审批" + }, + { + "id": "76265423c665e0ca404f991fd2d50550", + "name": "广播电视视频点播业务许可证(乙种)审批" + }, + { + "id": "e84b4a725837fdc42e265b7eb20cb9aa", + "name": "国内水路运输业务经营许可" + }, + { + "id": "65fc22fcce9688aea254dd1c53bb233c", + "name": "国内文艺表演团体设立审批" + }, + { + "id": "eb0084268a4b430228d3ca012067e7b9", + "name": "河道采砂许可" + }, + { + "id": "8a599850d018391d64df36b53cf570d8", + "name": "互联网上网服务营业场所信息网络安全审核" + }, + { + "id": "54024a38b74b1ef8b085b7b79b628c31", + "name": "机动车驾驶培训机构备案" + }, + { + "id": "5c05f1b68e1bc4e879dafbebbdc12491", + "name": "机动车维修经营备案" + }, + { + "id": "c78f43f131ad63bd21822ddc2e742850", + "name": "建设项目环境影响评价文件审批" + }, + { + "id": "0591ea9b-871f-4e8c-846e-5e603b68c7b1", + "name": "金属冶炼建设项目安全设施设计审查" + }, + { + "id": "509b2872fc7c38c08f252a2b426fd49f", + "name": "仅销售预包装食品备案" + }, + { + "id": "6a6ca32900a7959129c8080d304b5a8b", + "name": "经营高危险性体育项目许可" + }, + { + "id": "40919b34-62aa-499d-b53d-341ece876016", + "name": "经营个人征信业务审批" + }, + { + "id": "a1aedbb1-736c-4a70-8d73-7d593273a52b", + "name": "经营企业征信业务的机构备案" + }, + { + "id": "8dea1f04ecbe80d1b01e5eade81f0453", + "name": "经营性道路普通货物运输许可" + }, + { + "id": "33280c3ebdd72bec6760bfbfe4a7cb29", + "name": "经营性道路危险货物运输许可" + }, + { + "id": "2c797b336e693c5aa5cee07a67cfd3a1", + "name": "经营性人力资源服务许可" + }, + { + "id": "c087744a76cc89f548844a980317e59c", + "name": "举办国内营业性演出审批" + }, + { + "id": "1fa78e0ec48e1db38916cb41ac79857d", + "name": "开采矿产资源审批" + }, + { + "id": "014001178e556779c4e355982de513dc", + "name": "劳务派遣单位分支机构备案" + }, + { + "id": "9faeb7bc905378438589418d38c512ba", + "name": "劳务派遣经营许可" + }, + { + "id": "1c536a650ff1e35fd97eadc45c6b9037", + "name": "粮食收购备案" + }, + { + "id": "id7h4ad89993ba572db7b818ec85ce5e", + "name": "林草种子(普通)生产经营许可证核发" + }, + { + "id": "8f7987bccbe735692093bbe7edbb79d8", + "name": "林木采伐许可证核发" + }, + { + "id": "993e8ee7c5bb6cca27d6e4b50417c396", + "name": "旅馆业特种行业许可证核发" + }, + { + "id": "c14bc96ed8bd14fda099c0bda9b83f61", + "name": "民办职业培训学校新设立、变更" + }, + { + "id": "fe68b3e9f7fdb30b8d5f88bd3b2fe7a0", + "name": "母婴保健专项技术服务许可" + }, + { + "id": "daab52945a9e66fef3dc9f006b290bdf", + "name": "农药登记" + }, + { + "id": "f4b54f92ac348d2e082c17e5e3968865", + "name": "农药经营许可" + }, + { + "id": "1d4ee0ff17322c55ac4958b537fa2a23", + "name": "农药生产许可" + }, + { + "id": "15337e2528497f5891a9acf468a96d70", + "name": "农作物种子、食用菌菌种生产经营许可证核发" + }, + { + "id": "2280998efad08cd6d00b215414439c90", + "name": "农作物种子生产经营(进出口)许可证核发" + }, + { + "id": "96278efeaf0b283c46e1fadf4dac9301", + "name": "农作物种子生产经营(外商投资企业)许可证核发" + }, + { + "id": "0497f3a50dc2ce26aeb03b5a833ba286", + "name": "农作物种子生产经营分支机构备案" + }, + { + "id": "03013965cff1a59698a54f6c94e4a975", + "name": "排污许可证核发" + }, + { + "id": "6bac0814f3b4d7fa2f1230231710ea94", + "name": "取水许可" + }, + { + "id": "9666cd6f586d02762e8b94e9034c010b", + "name": "燃气经营许可证核发" + }, + { + "id": "6072dd6afb3260e6e86f32c7dafb739e", + "name": "燃气燃烧器具安装、维修企业资质核准" + }, + { + "id": "3a103cfb9993ba572db7b818ec85ce5e", + "name": "人工繁育国家重点保护野生动物审批(林业类)" + }, + { + "id": "5bddd6f9ed79bf983833b4b2685eb0af", + "name": "人力资源服务(不含职业中介活动、劳务派遣服务)备案" + }, + { + "id": "3f900cc04708d086380afc4a2b0d206c", + "name": "设立健身气功活动站点审批" + }, + { + "id": "69cd32e6a673bc017f064664343e4966", + "name": "社会力量举办非学历教育机构审批" + }, + { + "id": "7c47f77fc3203e4bd946b16831e50ac7", + "name": "申请从事互联网上网服务经营活动审批" + }, + { + "id": "f25650cef89c3c66aab0229ff9909dd1", + "name": "生鲜乳收购站许可" + }, + { + "id": "dc57e3f6c54a62d7e3cf0fc1b33e7cd8", + "name": "生鲜乳准运证明核发" + }, + { + "id": "16e96bf1-fb4f-42bf-98e9-5307f154b513", + "name": "食品经营许可-餐饮服务" + }, + { + "id": "54a79077-bd72-4ea9-8bb1-35afc69e2973", + "name": "食品经营许可-食品销售" + }, + { + "id": "2b12555dfcb8d4c00d32d9170f705800", + "name": "食品生产许可" + }, + { + "id": "0e774a1157608ff22d3be62c2441d027", + "name": "食品小作坊登记" + }, + { + "id": "966f4c5389c093b06c0003b6f4bd9ea7", + "name": "食用菌菌种生产经营许可证核发" + }, + { + "id": "9cf4a6d006a16ade6bae428cf89f4864", + "name": "兽药经营许可证核发(非生物制品类)" + }, + { + "id": "a34b0d8ab2378a222ab940106a45c2f1", + "name": "水产苗种场(不含原种场)的水产苗种生产许可证核发" + }, + { + "id": "429bd2f7fc00d2d928f1bf3a22a9e5dd", + "name": "水产苗种进出口审批" + }, + { + "id": "45eddd7ff9d0b85143a8ca2fda1d39eb", + "name": "水产原种场的水产苗种生产许可证核发" + }, + { + "id": "787ed631e137dbc03a6f2f8fa38c2bd2", + "name": "水路运输辅助业登记备案" + }, + { + "id": "71203b16487e96e8ee2f073d5df764ac", + "name": "水域滩涂养殖证核发" + }, + { + "id": "571426f8-2099-4745-a623-73fab88be54c", + "name": "特种设备使用登记" + }, + { + "id": "7c89a34348412d9327ff25f2fe7381ac", + "name": "托育机构备案" + }, + { + "id": "a071ebc11e97bfc2c39749f82517cc7d", + "name": "外商投资农作物新品种选育和种子生产经营审批" + }, + { + "id": "c7c7f51ca840c7cfb6ef344276bf914d", + "name": "危险废物收集经营许可证核发" + }, + { + "id": "01620f6e29d4e282a2b7854795446541", + "name": "危险化学品建设项目安全条件审查、安全设施设计审查" + }, + { + "id": "c3cf098fa5a36d55bac68657be8f1958", + "name": "危险化学品经营许可证核发" + }, + { + "id": "f885c920425f99ea3f8d5617d26a4819", + "name": "校车使用许可" + }, + { + "id": "44ce24e29a9d7fcc93ea1cd5a6cf4826", + "name": "校车使用许可" + }, + { + "id": "a7db266d402b10a24003e9bcee68fbc3", + "name": "学前教育机构" + }, + { + "id": "0b3c9ebffc32717af7eab0018e8aada3", + "name": "巡游出租汽车车辆运营证核发" + }, + { + "id": "6f256f454d5e1aaf48ec8e5a4fd55a8f", + "name": "巡游出租汽车经营许可" + }, + { + "id": "8a130133-b3c4-44c6-ad80-c1c201da15d8", + "name": "烟草专卖零售许可(电子烟)" + }, + { + "id": "9b8c389a0d49508c78a7345ec4fb02f2", + "name": "烟草专卖零售许可证" + }, + { + "id": "dae383b05d9a99c7bc157271c61f0655", + "name": "烟花爆竹经营许可证核发" + }, + { + "id": "or54d56026631uq5gb3c60dc5ca98sgd", + "name": "养老机构备案" + }, + { + "id": "3c4b5347a4476d891214a34811a5d0db", + "name": "药品经营许可证核准(零售)" + }, + { + "id": "aad5a9f345bb87d7f0fd6cef6fee9fdf", + "name": "医疗废物经营许可证核发" + }, + { + "id": "7c75fb4bf0e537d4d395523633bfc025", + "name": "医疗机构(不含诊所)执业登记" + }, + { + "id": "4e7e34a18c75ba6c7ac85f5eab87a233", + "name": "医疗机构(三级医院、三级妇幼保健院、急救中心、急救站、临床检验中心、中外合资合作医疗机构、港澳台独资医疗机构)设置审批" + }, + { + "id": "e171d96e91cf63bda6c1ac78e876e001", + "name": "饮用水供水单位卫生许可" + }, + { + "id": "f770843f4a910bae9767e81956961713", + "name": "游艺娱乐场所从事娱乐场所经营活动审批" + }, + { + "id": "7837ac6c5c0c3aae87652f56da53fe19", + "name": "诊所备案" + }, + { + "id": "wo9dd56026631uq5gb3c60dc5ca22b4b", + "name": "中介机构从事代理记账业务审批" + }, + { + "id": "f295e6c8619a5e99f2b545386217ed50", + "name": "中医诊所备案" + }, + { + "id": "a99ba07a5cd4e84a7e4d614aa8823e59", + "name": "种畜禽生产经营许可" + }, + { + "id": "aa70e0352a81f456cdceba85efb090af", + "name": "重要水产苗种进出口审批" + }, + { + "id": "21feb3ae0987ed9cfec1204aad2979ed", + "name": "转基因棉花种子生产经营许可证核发(初审)" + }, + { + "id": "b6b1e9f37c3a70aef25dc78d7ab7bf77", + "name": "转基因农作物种子生产经营许可证核发" + }, + { + "id": "baeb999d7a7ed56803b25af6c1d5a0cc", + "name": "转基因水产苗种生产经营许可证核发" + } + ] +} \ No newline at end of file diff --git a/smart_cors_middleware.py b/smart_cors_middleware.py new file mode 100644 index 0000000..f7f01b9 --- /dev/null +++ b/smart_cors_middleware.py @@ -0,0 +1,231 @@ +import os +from typing import Iterable, Tuple + +from flask import Flask, request, Response, jsonify +from datetime import datetime + + +def _get_bool(name: str, default: bool = False) -> bool: + v = os.getenv(name) + if v is None: + return default + return str(v).strip().lower() in {"1", "true", "yes", "on"} + + +def _parse_allowed(origins_raw: str | None) -> list[str]: + if not origins_raw: + return ["*"] + parts = [p.strip() for p in origins_raw.split(",") if p.strip()] + return parts or ["*"] + + +def _origin_matches(origin: str, allowed: Iterable[str], strict: bool) -> tuple[bool, str | None]: + # strict=True: only exact match items that are not wildcards + # strict=False: allow simple suffix matches: .example.com or *.example.com + for pat in allowed: + if pat == "*": + if strict: + # strict mode ignores wildcard + continue + return True, "*" + if origin == pat: + return True, pat + if not strict: + if pat.startswith("*."): + suf = pat[1:] # .example.com + if origin.endswith(suf): + return True, pat + if pat.startswith("."): + if origin.endswith(pat): + return True, pat + return False, None + + +def init_smart_cors(app: Flask) -> None: + """Attach Smart CORS handlers to a Flask app. + + Env vars: + - ALLOWED_ORIGINS: comma-separated, e.g. https://a.com,https://b.com or * + - CORS_STRICT: true/false (default false) + - CORS_DEBUG: true/false + - NGINX_CORS_MODE: true/false (when Nginx sets Allow-Origin; app only supplements others) + - CORS_MAX_AGE: seconds for preflight caching (default 86400) + - CORS_EXPOSE_HEADERS: override exposed headers list + """ + + allowed = _parse_allowed(os.getenv("ALLOWED_ORIGINS")) + + + # 添加前端实际使用的域名 + frontend_origins = [ + "http://chinaweal.com.cn:8090", + "http://www.chinaweal.com.cn:8090", + "https://chinaweal.com.cn", + "https://www.chinaweal.com.cn", + "http://172.22.80.130:8000", # 从日志中看到的referer + ] + + # 添加默认的本地开发源 + default_origins = [ + "http://localhost:3000", + "http://localhost:8000", + "http://localhost:8081", + "http://127.0.0.1:3000", + "http://127.0.0.1:8000", + "http://127.0.0.1:8081" + ] + + # 合并并去重 + all_origins = list(set(allowed + frontend_origins + default_origins)) + strict = _get_bool("CORS_STRICT", False) + debug = _get_bool("CORS_DEBUG", False) + nginx_mode = _get_bool("NGINX_CORS_MODE", False) + max_age = os.getenv("CORS_MAX_AGE", "86400") + expose_headers = os.getenv( + "CORS_EXPOSE_HEADERS", + "Content-Length, Content-Type, Authorization, X-Request-Id", + ) + + def _is_proxy_request() -> bool: + hdrs = request.headers + return any(h in hdrs for h in ("X-Forwarded-For", "X-Forwarded-Proto", "X-Real-IP")) + + def _log(level: str, msg: str, **fields): + if not debug and level == "DEBUG": + return + logger = app.logger + kv = " ".join(f"{k}={v}" for k, v in fields.items()) if fields else "" + logger.log( + 20 if level == "INFO" else 10 if level == "DEBUG" else 30, + f"[CORS] {msg} {kv}".rstrip(), + ) + + @app.before_request + def _handle_preflight(): # type: ignore + if request.method != "OPTIONS": + return None + origin = request.headers.get("Origin", "") + acrm = request.headers.get("Access-Control-Request-Method", "") + acrh = request.headers.get("Access-Control-Request-Headers", "") + + allowed_ok, matched = _origin_matches(origin, all_origins, strict) if origin else (False, None) + + # Build a minimal preflight response + resp = Response(status=204) + + # When Nginx handles Allow-Origin, do not override it + if not nginx_mode: + if allowed_ok: + if matched == "*": + # If behind proxy (likely Nginx will add AO), skip to avoid duplicates + if not _is_proxy_request(): + resp.headers["Access-Control-Allow-Origin"] = "*" + else: + _log("DEBUG", "proxy-skip-allow-origin(preflight)") + else: + resp.headers["Access-Control-Allow-Origin"] = origin + resp.headers["Access-Control-Allow-Credentials"] = "true" + resp.headers.add("Vary", "Origin") + # Always supplement the rest + resp.headers["Access-Control-Allow-Methods"] = acrm or "GET, POST, OPTIONS" + if acrh: + resp.headers["Access-Control-Allow-Headers"] = acrh + else: + resp.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-Requested-With, X-Request-Id" + ) + resp.headers["Access-Control-Max-Age"] = max_age + resp.headers["Access-Control-Expose-Headers"] = expose_headers + + decision = "deny" + if not origin: + decision = "no-origin" + elif allowed_ok: + decision = "allow-*" if matched == "*" else "allow-origin" + resp.headers["X-CORS-Decision"] = f"preflight; {decision}; nginx_mode={nginx_mode}" + + _log( + "INFO", + "preflight", + path=request.path, + origin=origin, + request_method=acrm or "", + request_headers=acrh or "", + allowed=allowed_ok, + matched=matched or "", + nginx_mode=nginx_mode, + decision=decision, + ) + return resp + + @app.after_request + def _add_cors_headers(response: Response): # type: ignore + origin = request.headers.get("Origin", "") + if not origin: + return response + + allowed_ok, matched = _origin_matches(origin, all_origins, strict) + + # When Nginx sets Allow-Origin, we only supplement others to avoid duplicates + if not nginx_mode: + if allowed_ok: + if matched == "*": + # Do not set credentials when wildcard is used; if proxied, skip to avoid dup + if not _is_proxy_request(): + response.headers.setdefault("Access-Control-Allow-Origin", "*") + else: + _log("DEBUG", "proxy-skip-allow-origin(response)") + else: + response.headers.setdefault("Access-Control-Allow-Origin", origin) + response.headers.setdefault("Access-Control-Allow-Credentials", "true") + # Ensure caches vary by Origin when dynamic + vary_val = response.headers.get("Vary") + if not vary_val: + response.headers["Vary"] = "Origin" + elif "Origin" not in vary_val: + response.headers["Vary"] = f"{vary_val}, Origin" + + # Expose common headers (safe to always include) + response.headers.setdefault("Access-Control-Expose-Headers", expose_headers) + + # Add decision header and log + decision = "deny" + if allowed_ok: + decision = "allow-*" if matched == "*" else "allow-origin" + response.headers.setdefault( + "X-CORS-Decision", + f"response; {decision}; nginx_mode={nginx_mode}", + ) + _log( + "INFO", + "response", + path=request.path, + method=request.method, + origin=origin, + allowed=allowed_ok, + matched=matched or "", + nginx_mode=nginx_mode, + decision=decision, + ) + return response + + # Diagnosis endpoint to aid verification and debugging + @app.get("/api/cors-diagnosis") + def cors_diagnosis(): # type: ignore + origin = request.headers.get("Origin", "") + ok, matched = _origin_matches(origin, all_origins, strict) if origin else (False, None) + data = { + "nginx_mode": nginx_mode, + "detected_proxy": _is_proxy_request(), + "allowed_origins": all_origins, + "strict": strict, + "request_origin": origin, + "matched_rule": matched, + "origin_allowed": ok, + "notes": ( + "Nginx handles Allow-Origin; app supplements other headers" + if nginx_mode + else "App sets CORS headers end-to-end" + ), + } + return jsonify(data)