229 lines
13 KiB
Python
229 lines
13 KiB
Python
"""BR-61/62: fictional corporate lease corpus and bounded read-only selections."""
|
||
from __future__ import annotations
|
||
import json
|
||
import re
|
||
import sqlite3
|
||
from datetime import datetime, timezone, timedelta
|
||
from services import assistant_store as store
|
||
|
||
COLUMNS = ('id', 'lessee', 'industry', 'mode', 'asset', 'amount', 'term', 'outcome',
|
||
'year', 'guarantee', 'issue', 'supplements', 'meeting_reason', 'data_domain',
|
||
'operating_cashflow', 'asset_value', 'revenue', 'total_liabilities', 'total_assets', 'initial_snapshot')
|
||
INDUSTRIES = ('制造业', '医疗健康', '清洁能源', '物流运输', '工程建设')
|
||
MODES = ('直接租赁', '售后回租')
|
||
OUTCOMES = ('通过', '否决', '暂缓', '未送审')
|
||
SOURCE = '虚构金融租赁公司对公业务 · corporate-lease-demo.v1 · 不含真实客户或审批制度'
|
||
|
||
|
||
class LibraryError(ValueError):
|
||
def __init__(self, code, message):
|
||
super().__init__(message)
|
||
self.code = code
|
||
|
||
|
||
def initialize(path):
|
||
store.initialize(path)
|
||
with store.transaction(path) as db:
|
||
if db.execute("SELECT 1 FROM assistant_meta WHERE key='lease_library_v1'").fetchone():
|
||
financial_migration(db)
|
||
return
|
||
db.execute('PRAGMA journal_mode=WAL')
|
||
db.execute('''CREATE TABLE IF NOT EXISTS lease_cases (
|
||
id TEXT PRIMARY KEY, lessee TEXT NOT NULL, industry TEXT NOT NULL,
|
||
mode TEXT NOT NULL, asset TEXT NOT NULL, amount REAL NOT NULL,
|
||
term INTEGER NOT NULL, outcome TEXT NOT NULL, year INTEGER NOT NULL,
|
||
guarantee TEXT NOT NULL, issue TEXT NOT NULL, supplements TEXT NOT NULL,
|
||
meeting_reason TEXT NOT NULL, data_domain TEXT NOT NULL CHECK(data_domain='demo'))''')
|
||
assets = ('数控加工中心与工业机器人', '磁共振与检验设备', '光伏发电组件与逆变器',
|
||
'冷链货运车辆与分拣设备', '起重机与挖掘机')
|
||
issues = ('经营现金流来源待核验', '租赁物合同、发票与编号需交叉核对',
|
||
'增信方代偿能力待核验', '融资金额与租赁物价值口径不一致')
|
||
reasons = ('补件后权属与回款依据完整,模拟评审会通过',
|
||
'核心偿债与租赁物适格性问题未消除,模拟评审会否决',
|
||
'仍有待核实材料,模拟评审会暂缓;不是否决标签',
|
||
'客户经理未提交评审会,无可观测审批结论')
|
||
for i in range(240):
|
||
sector = i % len(INDUSTRIES)
|
||
outcome = OUTCOMES[(i // 5) % 4]
|
||
row = (f'LEASE-{i+1:04d}', f'模拟{INDUSTRIES[sector]}承租企业{i+1:03d}有限公司',
|
||
INDUSTRIES[sector], MODES[(i // 3) % 2], assets[sector],
|
||
float(500 + ((i * 137) % 9500)), (24, 36, 48, 60)[i % 4], outcome,
|
||
2023 + ((i // 20) % 3), ('保证金+法人连带保证', '租赁物抵押+第三方法人保证')[i % 2],
|
||
issues[(i // 7) % 4], '银行流水、购置合同、设备发票、核对清单;均为模拟记录',
|
||
reasons[OUTCOMES.index(outcome)], 'demo')
|
||
db.execute('INSERT OR IGNORE INTO lease_cases (' + ','.join(COLUMNS[:14]) + ') VALUES (' + ','.join('?' * 14) + ')', row)
|
||
db.execute("INSERT INTO assistant_meta VALUES('lease_library_v1',?)", (store.now(),))
|
||
store.event(db, '', 'lease_library.seed', 'system-demo', {'count': 240, 'source': SOURCE})
|
||
financial_migration(db)
|
||
|
||
|
||
|
||
def financial_migration(db):
|
||
"""Append-only v2 migration: synthetic as-of financial facts, not meeting labels."""
|
||
if db.execute("SELECT 1 FROM assistant_meta WHERE key='lease_library_v2'").fetchone():
|
||
return
|
||
columns = {row[1] for row in db.execute('PRAGMA table_info(lease_cases)')}
|
||
additions = {'operating_cashflow': 'REAL NOT NULL DEFAULT 0', 'asset_value': 'REAL NOT NULL DEFAULT 0',
|
||
'revenue': 'REAL NOT NULL DEFAULT 0', 'total_liabilities': 'REAL NOT NULL DEFAULT 0',
|
||
'total_assets': 'REAL NOT NULL DEFAULT 0', 'initial_snapshot': "TEXT NOT NULL DEFAULT '{}'"}
|
||
for column, definition in additions.items():
|
||
if column not in columns:
|
||
db.execute(f'ALTER TABLE lease_cases ADD COLUMN {column} {definition}')
|
||
for i, row in enumerate(db.execute('SELECT id,amount,year FROM lease_cases ORDER BY id').fetchall()):
|
||
amount = row['amount']
|
||
cash = round(amount * (0.08 + (i % 9) * 0.09), 2)
|
||
asset = round(amount * (0.85 + (i % 7) * 0.1), 2)
|
||
revenue = round(amount * (1.8 + (i % 11) * 0.3), 2)
|
||
liabilities = round(revenue * (0.25 + (i % 6) * 0.07), 2)
|
||
total_assets = round(liabilities + amount * (1.1 + (i % 5) * 0.2), 2)
|
||
initial = {'as_of': f"{row['year']}-01-15", 'source': '虚构初始材料快照(不含评审结果)',
|
||
'values': {'facility.amount': amount, 'financial.operating_cashflow': cash,
|
||
'lease.total_value': asset, 'financial.total_liabilities': liabilities,
|
||
'financial.total_assets': total_assets},
|
||
'ownership_material_status': 'provided_pending_check' if i % 3 else 'missing',
|
||
'bank_flow_material_status': 'provided_pending_check' if i % 4 else 'missing'}
|
||
db.execute('UPDATE lease_cases SET operating_cashflow=?,asset_value=?,revenue=?,total_liabilities=?,total_assets=?,initial_snapshot=? WHERE id=?',
|
||
(cash, asset, revenue, liabilities, total_assets, json.dumps(initial, ensure_ascii=False), row['id']))
|
||
db.execute("INSERT INTO assistant_meta VALUES('lease_library_v2',?)", (store.now(),))
|
||
|
||
|
||
def filters_sql(filters):
|
||
unknown = set(filters) - {'industry', 'mode', 'outcome', 'year', 'min_amount', 'max_amount'}
|
||
if unknown:
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '筛选条件不受支持,请修改后重新预览')
|
||
terms, args = [], []
|
||
for field, allowed in (('industry', INDUSTRIES), ('mode', MODES), ('outcome', OUTCOMES)):
|
||
value = filters.get(field)
|
||
if value:
|
||
if value not in allowed:
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '筛选条件不受支持,请修改后重新预览')
|
||
terms.append(field + '=?')
|
||
args.append(value)
|
||
for field in ('year', 'min_amount', 'max_amount'):
|
||
value = filters.get(field)
|
||
if value not in (None, ''):
|
||
try:
|
||
numeric = float(value)
|
||
if not 0 <= numeric <= 100000000 or (field == 'year' and numeric not in (2023, 2024, 2025)):
|
||
raise ValueError()
|
||
except (TypeError, ValueError):
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '金额或年份筛选不合法') from None
|
||
terms.append({'year': 'year=?', 'min_amount': 'amount>=?', 'max_amount': 'amount<=?'}[field])
|
||
args.append(numeric)
|
||
if filters.get('min_amount') not in (None, '') and filters.get('max_amount') not in (None, ''):
|
||
if float(filters['min_amount']) > float(filters['max_amount']):
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '金额下限不能大于上限')
|
||
return 'SELECT * FROM lease_cases' + (' WHERE ' + ' AND '.join(terms) if terms else '') + ' ORDER BY id', args
|
||
|
||
|
||
def natural_filters(text):
|
||
"""Controlled Chinese vocabulary: ambiguous leftovers reject, never broaden."""
|
||
remainder = text.strip()
|
||
if not remainder:
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '请输入要筛选的案例条件')
|
||
result = {}
|
||
for field, options in (('industry', INDUSTRIES), ('mode', MODES), ('outcome', OUTCOMES)):
|
||
found = [word for word in options if word in remainder]
|
||
if len(found) > 1:
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '多个同类条件存在歧义,请用筛选按钮或 SQL')
|
||
if found:
|
||
result[field] = found[0]
|
||
remainder = remainder.replace(found[0], '')
|
||
for match in re.findall(r'202[345]', remainder):
|
||
if 'year' in result:
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '请选择一个年份或改用 SQL')
|
||
result['year'] = int(match)
|
||
remainder = remainder.replace(match, '', 1)
|
||
for prefix, key in (('不少于|大于等于|至少|>=', 'min_amount'), ('不超过|小于等于|至多|<=', 'max_amount')):
|
||
pattern = rf'(?:金额)?(?:{prefix})\s*(\d+(?:\.\d+)?)\s*万(?:元)?'
|
||
match = re.search(pattern, remainder)
|
||
if match:
|
||
result[key] = float(match.group(1))
|
||
remainder = remainder[:match.start()] + remainder[match.end():]
|
||
remainder = re.sub(r'全部|所有|筛选|选择|查找|查询|展示|历史|案例|项目|业务|对公|租赁|金融|已经|已|中的|的|和|且|以及|年|数据|[\s,,。.]', '', remainder)
|
||
if remainder:
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', f'无法确认筛选含义「{remainder[:40]}」,请用筛选按钮或只读 SQL')
|
||
return result
|
||
|
||
|
||
def read_query(db, sql, args=()):
|
||
query = sql.strip().rstrip(';').strip()
|
||
if not re.match(r'^SELECT\s+\*\s+FROM\s+lease_cases\b', query, re.I) or re.search(r'\b(UNION|JOIN)\b', query, re.I) or ';' in query or len(query) > 4000:
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '仅支持对 lease_cases 的一条 SELECT 查询')
|
||
steps = [0]
|
||
def authorize(action, first, second, database, source):
|
||
if action == sqlite3.SQLITE_SELECT:
|
||
return sqlite3.SQLITE_OK
|
||
if action == sqlite3.SQLITE_READ and first == 'lease_cases' and second in (*COLUMNS, ''):
|
||
return sqlite3.SQLITE_OK
|
||
if action == sqlite3.SQLITE_FUNCTION and str(second).lower() in ('count', 'sum', 'avg', 'min', 'max', 'round', 'like', 'lower', 'upper', 'coalesce'):
|
||
return sqlite3.SQLITE_OK
|
||
return sqlite3.SQLITE_DENY
|
||
def budget():
|
||
steps[0] += 1
|
||
return int(steps[0] > 1000)
|
||
db.set_authorizer(authorize)
|
||
db.set_progress_handler(budget, 1000)
|
||
try:
|
||
cursor = db.execute(query, args)
|
||
rows = cursor.fetchmany(241)
|
||
if len(rows) > 240:
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '结果超过 240 行,请缩小筛选范围')
|
||
if tuple(item[0] for item in cursor.description) != COLUMNS:
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '请使用 SELECT * 保留案例编号和完整字段')
|
||
items = [dict(row) for row in rows]
|
||
if len({row['id'] for row in items}) != len(items):
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '查询产生重复案例,请简化 SELECT')
|
||
return items
|
||
except sqlite3.DatabaseError:
|
||
raise LibraryError('LIBRARY_QUERY_INVALID', '筛选条件不受支持,请修改后重新预览') from None
|
||
finally:
|
||
db.set_authorizer(None)
|
||
db.set_progress_handler(None, 0)
|
||
|
||
|
||
def preview(db, actor, mode, text='', filters=None):
|
||
parsed = natural_filters(text) if mode == 'natural' else (filters or {})
|
||
sql, args = (text, []) if mode == 'sql' else filters_sql(parsed)
|
||
items = read_query(db, sql, args)
|
||
body = {'id': store.uid('selection.preview'), 'created_by': actor, 'created_at': store.now(),
|
||
'expires_at': (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(),
|
||
'mode': mode, 'sql': sql, 'filters': parsed, 'case_ids': [r['id'] for r in items],
|
||
'source_hash': store.digest(items), 'count': len(items), 'source': SOURCE}
|
||
store.put(db, 'lease_query_preview', body, immutable=True)
|
||
return {**body, 'preview_id': body['id'], 'items': items, 'total': 240, 'schema': list(COLUMNS)}
|
||
|
||
|
||
def validate_selection(body, actor):
|
||
if body['created_by'] != actor or datetime.fromisoformat(body['expires_at']) < datetime.now(timezone.utc):
|
||
raise LibraryError('LIBRARY_SELECTION_INVALID', '请重新预览并确认要加工的案例')
|
||
|
||
|
||
def confirm(db, actor, preview_id, selected_ids=None):
|
||
try:
|
||
body = store.get(db, 'lease_query_preview', preview_id)
|
||
except KeyError:
|
||
raise LibraryError('LIBRARY_SELECTION_INVALID', '请重新预览并确认要加工的案例') from None
|
||
validate_selection(body, actor)
|
||
ids = list(dict.fromkeys(selected_ids)) if selected_ids is not None else body['case_ids']
|
||
if not ids or not set(ids).issubset(body['case_ids']):
|
||
raise LibraryError('LIBRARY_SELECTION_INVALID', '请重新预览并确认要加工的案例')
|
||
items = [dict(db.execute('SELECT * FROM lease_cases WHERE id=?', (case_id,)).fetchone()) for case_id in ids]
|
||
selection = {**body, 'id': store.uid('selection.confirmed'), 'case_ids': ids, 'count': len(ids),
|
||
'preview_id': preview_id, 'source_hash': store.digest(items), 'confirmed_at': store.now()}
|
||
store.put(db, 'lease_selection', selection, immutable=True)
|
||
store.event(db, '', 'lease_library.selection', actor, {'selection_id': selection['id'], 'count': len(ids)})
|
||
return selection
|
||
|
||
|
||
def selected_cases(db, actor, selection_id):
|
||
try:
|
||
selection = store.get(db, 'lease_selection', selection_id)
|
||
except KeyError:
|
||
raise LibraryError('LIBRARY_SELECTION_INVALID', '请重新预览并确认要加工的案例') from None
|
||
validate_selection(selection, actor)
|
||
items = [dict(db.execute('SELECT * FROM lease_cases WHERE id=?', (key,)).fetchone()) for key in selection['case_ids']]
|
||
if not items or store.digest(items) != selection['source_hash']:
|
||
raise LibraryError('LIBRARY_SELECTION_INVALID', '案例版本已改变,请重新确认')
|
||
return selection, items
|