22
33from contextlib import contextmanager
44from datetime import UTC , datetime
5- from typing import Dict , List , Optional , Tuple , Any
5+ from typing import Dict , Optional , Any , List
6+ import json
67
78import numpy as np
89import psycopg
910from pgvector .psycopg import register_vector
1011from psycopg .rows import dict_row
1112
13+ from clustering .cluster_scoring import (
14+ compute_entity_similarity ,
15+ compute_final_score ,
16+ )
17+ from clustering .cluster_thresholds import (
18+ SEMANTIC_SIMILARITY_THRESHOLD ,
19+ FINAL_SCORE_THRESHOLD ,
20+ )
1221
13- class DatabaseManager :
14- """Manages unified database operations using PostgreSQL."""
1522
23+ class DatabaseManager :
1624 def __init__ (self , db_url : str , embedding_dimension : int = 1536 ):
17- """Initialize the database manager and ensure schema is ready."""
1825 self .db_url = db_url
1926 self .embedding_dimension = embedding_dimension
2027 self .setup_database ()
2128
2229 @contextmanager
2330 def get_connection (self ):
24- """Context manager for PostgreSQL connections with pgvector registered."""
2531 conn = psycopg .connect (self .db_url , row_factory = dict_row , autocommit = False )
2632 register_vector (conn )
2733 try :
@@ -34,10 +40,8 @@ def get_connection(self):
3440 conn .close ()
3541
3642 def setup_database (self ):
37- """Initialize database and create tables (idempotent)."""
3843 with self .get_connection () as conn :
3944 cur = conn .cursor ()
40-
4145 cur .execute ("CREATE EXTENSION IF NOT EXISTS vector" )
4246
4347 cur .execute (
@@ -55,20 +59,14 @@ def setup_database(self):
5559 published_date TIMESTAMPTZ,
5660 item_type TEXT,
5761 created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
58- updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
62+ updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
63+ subject TEXT,
64+ organization_list JSONB,
65+ event_type TEXT,
66+ cluster_id INTEGER
5967 )
6068 """
6169 )
62-
63- cur .execute (
64- """
65- ALTER TABLE articles
66- ADD COLUMN IF NOT EXISTS subject TEXT,
67- ADD COLUMN IF NOT EXISTS organization_list JSONB,
68- ADD COLUMN IF NOT EXISTS event_type TEXT,
69- ADD COLUMN IF NOT EXISTS cluster_id INTEGER
70- """
71- )
7270
7371 cur .execute (
7472 f"""
@@ -82,136 +80,89 @@ def setup_database(self):
8280 """
8381 )
8482
85- cur .execute (
86- """
87- CREATE TABLE IF NOT EXISTS sync_history (
88- id SERIAL PRIMARY KEY,
89- source_site TEXT NOT NULL,
90- sync_mode TEXT NOT NULL,
91- last_sync_time TIMESTAMPTZ,
92- items_processed INTEGER DEFAULT 0,
93- created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
94- )
95- """
96- )
97-
98- cur .execute ("CREATE INDEX IF NOT EXISTS idx_content_hash ON articles(content_hash)" )
99- cur .execute ("CREATE INDEX IF NOT EXISTS idx_source_site ON articles(source_site)" )
10083 cur .execute ("CREATE INDEX IF NOT EXISTS idx_cluster_id ON articles(cluster_id)" )
10184
102- def _compute_content_hash (self , item : Dict ) -> str :
103- import hashlib
104- full_content = item .get ("full_content" , item .get ("description" , "" ))
105- return hashlib .sha256 (full_content .encode ()).hexdigest ()
106-
107- def save_article (self , item : Dict , conn : Optional [psycopg .Connection ] = None ) -> bool :
108- content_hash = self ._compute_content_hash (item )
109- if conn is None :
110- with self .get_connection () as owned_conn :
111- return self .save_article (item , owned_conn )
112- cur = conn .cursor ()
113- cur .execute (
114- """
115- INSERT INTO articles
116- (id, source_site, title, description, full_content, content_hash, author_info, keywords, content_url, published_date, item_type, created_at)
117- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
118- ON CONFLICT DO NOTHING
119- """ ,
120- (
121- item ["id" ],
122- item ["source_site" ],
123- item ["title" ],
124- item .get ("description" , "" ),
125- item .get ("full_content" , item .get ("description" , "" )),
126- content_hash ,
127- item .get ("author_info" , "" ),
128- item .get ("keywords" , "" ),
129- item ["content_url" ],
130- item .get ("published_date" , datetime .now (UTC )),
131- item .get ("item_type" , "article" ),
132- datetime .now (UTC ),
133- ),
134- )
135- return cur .rowcount > 0
136-
137- def article_exists (self , article_id : str ) -> bool :
138- with self .get_connection () as conn :
139- cur = conn .cursor ()
140- cur .execute ("SELECT 1 FROM articles WHERE id = %s" , (article_id ,))
141- return cur .fetchone () is not None
142-
143- def article_exists_by_hash (self , content_hash : str ) -> bool :
85+ def assign_cluster_with_similarity (
86+ self ,
87+ article_id : str ,
88+ subject : str ,
89+ orgs : List [str ],
90+ event : str ,
91+ ):
14492 with self .get_connection () as conn :
14593 cur = conn .cursor ()
146- cur .execute ("SELECT 1 FROM articles WHERE content_hash = %s" , (content_hash ,))
147- return cur .fetchone () is not None
14894
149- def save_embedding (self , article_id : str , embedding : np .ndarray , model : str = "default" ) -> bool :
150- with self .get_connection () as conn :
151- cur = conn .cursor ()
15295 cur .execute (
15396 """
154- INSERT INTO embeddings (article_id, embedding, embedding_model)
155- VALUES (%s, %s, %s)
156- ON CONFLICT (article_id) DO UPDATE
157- SET embedding = EXCLUDED.embedding,
158- embedding_model = EXCLUDED.embedding_model,
159- created_at = CURRENT_TIMESTAMP
97+ SELECT embedding FROM embeddings WHERE article_id = %s
16098 """ ,
161- (article_id , embedding . tolist (), model ),
99+ (article_id ,)
162100 )
163- return cur .rowcount > 0
101+ row = cur .fetchone ()
102+ if not row :
103+ return
104+
105+ article_embedding = row ["embedding" ]
164106
165- def assign_cluster_by_entities (self , article_id : str , subject : str , orgs : str , event : str ):
166- with self .get_connection () as conn :
167- cur = conn .cursor ()
168107 cur .execute (
169108 """
170- SELECT cluster_id FROM articles
171- WHERE subject = %s AND organization_list = %s::jsonb AND event_type = %s
172- AND cluster_id IS NOT NULL LIMIT 1
109+ SELECT a.cluster_id, a.subject, a.organization_list, a.event_type,
110+ 1 - (e.embedding <=> %s) AS semantic_similarity
111+ FROM articles a
112+ JOIN embeddings e ON a.id = e.article_id
113+ WHERE a.cluster_id IS NOT NULL
114+ AND (a.subject = %s OR a.event_type = %s)
173115 """ ,
174- (subject , orgs , event )
116+ (article_embedding , subject , event ),
175117 )
176- row = cur .fetchone ()
177- if row :
178- cid = row ['cluster_id' ]
179- else :
180- cur .execute ("SELECT COALESCE(MAX(cluster_id), 0) + 1 as next_id FROM articles" )
181- cid = cur .fetchone ()['next_id' ]
182-
118+
119+ best_cluster_id = None
120+ best_score = 0.0
121+
122+ for row in cur .fetchall ():
123+ semantic_score = row ["semantic_similarity" ]
124+
125+ if semantic_score < SEMANTIC_SIMILARITY_THRESHOLD :
126+ continue
127+
128+ entity_score = compute_entity_similarity (
129+ {
130+ "subject" : subject ,
131+ "orgs" : orgs ,
132+ "event" : event ,
133+ },
134+ {
135+ "subject" : row ["subject" ],
136+ "orgs" : row ["organization_list" ] or [],
137+ "event" : row ["event_type" ],
138+ },
139+ )
140+
141+ final_score = compute_final_score (semantic_score , entity_score )
142+
143+ if final_score > best_score :
144+ best_score = final_score
145+ best_cluster_id = row ["cluster_id" ]
146+
147+ if best_cluster_id is None or best_score < FINAL_SCORE_THRESHOLD :
148+ cur .execute ("SELECT COALESCE(MAX(cluster_id), 0) + 1 AS next_id FROM articles" )
149+ best_cluster_id = cur .fetchone ()["next_id" ]
150+
183151 cur .execute (
184152 """
185- UPDATE articles SET subject=%s, organization_list=%s::jsonb,
186- event_type=%s, cluster_id=%s, updated_at=CURRENT_TIMESTAMP
187- WHERE id=%s
153+ UPDATE articles
154+ SET subject = %s,
155+ organization_list = %s::jsonb,
156+ event_type = %s,
157+ cluster_id = %s,
158+ updated_at = CURRENT_TIMESTAMP
159+ WHERE id = %s
188160 """ ,
189- (subject , orgs , event , cid , article_id )
161+ (
162+ subject ,
163+ json .dumps (orgs ),
164+ event ,
165+ best_cluster_id ,
166+ article_id ,
167+ ),
190168 )
191-
192- def get_stats (self ) -> Dict :
193- with self .get_connection () as conn :
194- cur = conn .cursor ()
195- cur .execute ("SELECT COUNT(*) AS count FROM articles" )
196- total_articles = cur .fetchone ()["count" ]
197- cur .execute ("SELECT COUNT(*) AS count FROM embeddings" )
198- total_embeddings = cur .fetchone ()["count" ]
199- cur .execute ("SELECT COUNT(DISTINCT cluster_id) AS count FROM articles WHERE cluster_id IS NOT NULL" )
200- total_clusters = cur .fetchone ()["count" ]
201- cur .execute ("SELECT source_site, COUNT(*) as count FROM articles GROUP BY source_site" )
202- articles_by_source = {row ["source_site" ]: row ["count" ] for row in cur .fetchall ()}
203- return {
204- "total_articles" : total_articles ,
205- "total_embeddings" : total_embeddings ,
206- "articles_by_source" : articles_by_source ,
207- "articles_without_embeddings" : total_articles - total_embeddings ,
208- "total_clusters" : total_clusters ,
209- }
210-
211- def record_sync (self , source : str , mode : str , items_processed : int = 0 ):
212- with self .get_connection () as conn :
213- cur = conn .cursor ()
214- cur .execute (
215- "INSERT INTO sync_history (source_site, sync_mode, last_sync_time, items_processed) VALUES (%s, %s, %s, %s)" ,
216- (source , mode , datetime .now (UTC ), items_processed ),
217- )
0 commit comments