main
py 71 lines 5.13 KB
Raw
1 """Typed append-only V12 storage through the existing persistence connection."""
2 import json
3 from app.business_exposure import CompanyBusinessExposureProfile
4 from app.news_intelligence import SearchRun, EventImpactFeature
5
6 TABLES={
7 CompanyBusinessExposureProfile:('company_business_exposure_profiles','profile_id',
8 'profile_id instrument_id profile_version evidence_fingerprint public_available_at retrieved_at computed_at confidence'),
9 SearchRun:('research_news_search_runs','run_id',
10 'run_id instrument_id query_plan_version started_at completed_at outcome coverage qualifying_events'),
11 EventImpactFeature:('research_event_impact_features','feature_id',
12 'feature_id instrument_id event_key feature_version evidence_fingerprint profile_id source_document_id source_event_id event_type exposure_key direction magnitude impact_score relevance source_confidence event_confidence source_tier relevance_type publication_time public_available_at discovered_at computed_at valid_until short_term medium_term long_term'),
13 }
14
15 def sqlite_schema(connection):
16 for model,(table,pk,names) in TABLES.items():
17 columns=[]
18 for name in names.split():
19 kind='REAL' if name in {'confidence','coverage','magnitude','impact_score','relevance','source_confidence','event_confidence'} else 'INTEGER' if name in {'qualifying_events','direction','short_term','medium_term','long_term'} else 'TEXT'
20 nullable=name in {'source_event_id','exposure_key','publication_time','valid_until'}
21 columns.append(f'{name} {kind}'+(' PRIMARY KEY' if name==pk else '' if nullable else ' NOT NULL'))
22 columns.append('payload TEXT NOT NULL')
23 if model is EventImpactFeature:
24 columns.extend(['FOREIGN KEY(profile_id,instrument_id) REFERENCES company_business_exposure_profiles(profile_id,instrument_id)',
25 'FOREIGN KEY(source_document_id) REFERENCES research_documents(document_id)',
26 'FOREIGN KEY(source_event_id) REFERENCES research_events(event_id)',
27 'UNIQUE(instrument_id,event_key,feature_version,evidence_fingerprint)'])
28 if model is CompanyBusinessExposureProfile:
29 columns.extend(['UNIQUE(profile_id,instrument_id)','UNIQUE(instrument_id,profile_version,evidence_fingerprint)'])
30 connection.execute(f"CREATE TABLE IF NOT EXISTS {table} ({','.join(columns)})")
31 time='completed_at' if model is SearchRun else 'public_available_at'
32 connection.execute(f'CREATE INDEX IF NOT EXISTS ix_{table}_time ON {table}(instrument_id,{time})')
33 for action in ('UPDATE','DELETE'):
34 connection.execute(f"CREATE TRIGGER IF NOT EXISTS immutable_{table}_{action} BEFORE {action} ON {table} BEGIN SELECT RAISE(ABORT,'NEWS_HISTORY_IS_IMMUTABLE'); END")
35 connection.commit()
36
37 class NewsPersistenceMixin:
38 def append_news_record(self, record):
39 record=type(record).model_validate(record.model_dump())
40 table,pk,names=TABLES[type(record)]
41 payload=record.model_dump(mode='json')
42 key=payload[pk]
43 existing=self._connection.execute(f'SELECT payload FROM {table} WHERE {pk}=?',(key,)).fetchone()
44 if existing:
45 old=existing['payload']
46 old=old if isinstance(old,dict) else json.loads(old)
47 compare=dict(payload)
48 if 'computed_at' in old: compare['computed_at']=old['computed_at']
49 if compare!=old: raise ValueError('IMMUTABLE_NEWS_REVISION_CONFLICT')
50 return type(record).model_validate(old)
51 if isinstance(record,EventImpactFeature):
52 parent=self._connection.execute('SELECT instrument_id,public_available_at,computed_at FROM company_business_exposure_profiles WHERE profile_id=?',(str(record.profile_id),)).fetchone()
53 if not parent or str(parent['instrument_id'])!=str(record.instrument_id): raise ValueError('EXPOSURE_PROVENANCE_UNAVAILABLE')
54 from datetime import datetime
55 for field in ('public_available_at','computed_at'):
56 value=parent[field]
57 stamp=datetime.fromisoformat(value) if isinstance(value,str) else value
58 if stamp>getattr(record,field): raise ValueError('EXPOSURE_LOOKAHEAD')
59 cols=names.split()
60 with self._connection:
61 self._connection.execute(f"INSERT INTO {table} ({','.join(cols)},payload) VALUES ({','.join('?' for _ in range(len(cols)+1))})",
62 tuple(getattr(record,c).isoformat() if hasattr(getattr(record,c),'isoformat') else payload[c] for c in cols)+(json.dumps(payload,sort_keys=True),))
63 return record
64
65 def load_news_records(self, model, instrument_id, *, as_of):
66 table,pk,_=TABLES[model]
67 cutoff='completed_at' if model is SearchRun else 'computed_at'
68 rows=self._connection.execute(f'SELECT payload FROM {table} WHERE instrument_id=? AND {cutoff}<=? ORDER BY {cutoff},{pk}',
69 (str(instrument_id),as_of.isoformat())).fetchall()
70 values=[model.model_validate(row['payload'] if isinstance(row['payload'],dict) else json.loads(row['payload'])) for row in rows]
71 return [v for v in values if not hasattr(v,'public_available_at') or v.public_available_at<=as_of]