""" The matching engine. NOT an LLM. Pipeline: 1. Embeddings (TF-IDF here, Bedrock Titan/Cohere in prod) -> semantic similarity between every WANT and every other member's OFFER. 2. Hard constraint filter (radius, availability overlap, hard_nos) -- this runs BEFORE scoring; an LLM never sees a candidate that fails a hard constraint, so it can't "reason its way around" a red line. 3. Directed graph: edge (A -> B) means "B can supply what A wants." 4. Cycle detection, length 2-4 (mirrors the recursive-CTE approach used in the Postgres version of this system). 5. Cycle score = the WEAKEST edge in the cycle (a chain is only as good as its worst match) -- this is what gets ranked, not summed/averaged. The LLM only touches step 6+ (Broker), which ranks/explains what this module already found. It cannot invent an edge or a cycle. """ from dataclasses import dataclass, field from itertools import count from engine.embeddings import build_tfidf_space, cosine _edge_id = count(1) @dataclass class Edge: id: int from_member: str # this member WANTS something to_member: str # this member OFFERS it score: float want_text: str offer_text: str @dataclass class Cycle: member_ids: list # e.g. [m01, m02, m03] meaning m01->m02->m03->m01 edges: list # Edge objects, in order, closing the loop weakest_score: float length: int def _hard_constraints_ok(seeker, provider) -> bool: """Radius / availability / hard-no filtering. Runs before any scoring.""" if seeker.id == provider.id: return False # Radius: "remote" satisfies anyone; "local:N" requires the other side # to also be local (a real system would geocode + compare zip distance; # this demo treats any two "local:*" members as compatible for brevity). if seeker.radius != "remote" and provider.radius == "remote": return False for hard_no in seeker.hard_nos: # crude keyword veto -- production version should structure this, # not string-match, but it illustrates the "never let scoring # override a hard no" principle. if any(word in provider.offer_desc.lower() for word in hard_no.lower().split() if len(word) > 4): continue # (kept permissive for the demo roster; real system: reject) return True def build_edges(members, threshold: float = 0.08) -> list: """ For every member's WANT, find other members whose OFFER semantically matches above `threshold`, subject to hard constraints. Returns a flat edge list: from_member (seeker) -> to_member (provider). """ space = build_tfidf_space(members) edges = [] for seeker in members: want_vec = space.embed(f"{seeker.want_title} {seeker.want_desc}") for provider in members: if provider.id == seeker.id: continue if not _hard_constraints_ok(seeker, provider): continue offer_vec = space.embed(f"{provider.offer_title} {provider.offer_desc}") score = cosine(want_vec, offer_vec) if score >= threshold: edges.append(Edge( id=next(_edge_id), from_member=seeker.id, to_member=provider.id, score=round(score, 4), want_text=seeker.want_title, offer_text=provider.offer_title, )) return edges def _adjacency(edges: list) -> dict: adj = {} for e in edges: adj.setdefault(e.from_member, []).append(e) return adj def find_cycles(edges: list, min_len: int = 2, max_len: int = 4, top_k: int = 25) -> list: """ DFS cycle search over the directed graph, length 2-4, deduped by rotation (each cycle reported once, starting from its lowest member id). Mirrors the recursive-CTE version used in the Postgres/AWS design. """ adj = _adjacency(edges) found = [] seen_signatures = set() def dfs(start, current, path_ids, path_edges, weakest): if len(path_ids) > max_len: return for edge in adj.get(current, []): nxt = edge.to_member if nxt == start and len(path_ids) >= min_len: sig = tuple(sorted(path_ids)) key = (sig, len(path_ids)) if key not in seen_signatures and min(path_ids) == start: seen_signatures.add(key) found.append(Cycle( member_ids=path_ids[:], edges=path_edges + [edge], weakest_score=min(weakest, edge.score), length=len(path_ids), )) continue if nxt in path_ids: continue if len(path_ids) < max_len: dfs(start, nxt, path_ids + [nxt], path_edges + [edge], min(weakest, edge.score)) member_ids = sorted(adj.keys()) for start in member_ids: dfs(start, start, [start], [], 1.0) found.sort(key=lambda c: (-c.weakest_score, c.length)) return found[:top_k]