Files
mini-openclaw-v2/backend/services/lease_case_library.py
T

229 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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