harness-fleet 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- harness_fleet/__init__.py +186 -0
- harness_fleet/bundler.py +257 -0
- harness_fleet/calibrate.py +410 -0
- harness_fleet/candidates.py +147 -0
- harness_fleet/catalog.py +747 -0
- harness_fleet/cli.py +2025 -0
- harness_fleet/dag.py +555 -0
- harness_fleet/data/routes.seed.json +320 -0
- harness_fleet/discover.py +2701 -0
- harness_fleet/engine.py +712 -0
- harness_fleet/eval.py +409 -0
- harness_fleet/export.py +355 -0
- harness_fleet/grounding.py +425 -0
- harness_fleet/input_data.py +539 -0
- harness_fleet/mcp_server.py +445 -0
- harness_fleet/migrations/001_control_plane.sql +151 -0
- harness_fleet/migrations/002_intelligence_and_policy.sql +53 -0
- harness_fleet/migrations/003_profiles.sql +18 -0
- harness_fleet/migrations/004_studio_settings.sql +11 -0
- harness_fleet/models.py +1199 -0
- harness_fleet/packer.py +61 -0
- harness_fleet/partner.py +97 -0
- harness_fleet/profile.py +70 -0
- harness_fleet/providers/antigravity.py +61 -0
- harness_fleet/providers/base.py +115 -0
- harness_fleet/providers/claude.py +60 -0
- harness_fleet/providers/codex.py +126 -0
- harness_fleet/providers/cursor.py +69 -0
- harness_fleet/providers/demo.py +168 -0
- harness_fleet/providers/grok.py +68 -0
- harness_fleet/providers/harness.py +383 -0
- harness_fleet/providers/muse.py +143 -0
- harness_fleet/providers/openai_compatible.py +261 -0
- harness_fleet/providers/opencode.py +175 -0
- harness_fleet/providers/openrouter.py +232 -0
- harness_fleet/providers/registry.py +99 -0
- harness_fleet/resources/account_skill/SKILL.md +208 -0
- harness_fleet/resources/account_skill/references/discovery-playbook.md +168 -0
- harness_fleet/resources/account_skill/references/icp-decomposition.md +62 -0
- harness_fleet/resources/account_skill/references/icp-interview.md +192 -0
- harness_fleet/resources/account_skill/references/mcp-recipes.md +111 -0
- harness_fleet/resources/account_skill/references/scoring-rubric-guide.md +107 -0
- harness_fleet/resources/examples/account_research/sample_accounts.csv +11 -0
- harness_fleet/resources/examples/account_research/task.json +35 -0
- harness_fleet/resources/harness_skill/SKILL.md +57 -0
- harness_fleet/resources/harness_skill/agents/openai.yaml +6 -0
- harness_fleet/resources/harness_skill/references/operations.md +190 -0
- harness_fleet/resources/harness_skill/references/task-contracts.md +72 -0
- harness_fleet/resources/partner_skill/SKILL.md +215 -0
- harness_fleet/resources/partner_skill/references/discovery-playbook.md +116 -0
- harness_fleet/resources/partner_skill/references/ipp-decomposition.md +60 -0
- harness_fleet/resources/partner_skill/references/ipp-interview.md +148 -0
- harness_fleet/resources/partner_skill/references/mcp-recipes.md +77 -0
- harness_fleet/resources/partner_skill/references/scoring-rubric-guide.md +110 -0
- harness_fleet/resources/studio/index.html +320 -0
- harness_fleet/scoring.py +251 -0
- harness_fleet/sessions.py +106 -0
- harness_fleet/setup.py +231 -0
- harness_fleet/slicer.py +83 -0
- harness_fleet/store.py +1863 -0
- harness_fleet/studio.py +475 -0
- harness_fleet/task.py +640 -0
- harness_fleet/ui.py +118 -0
- harness_fleet-0.4.0.data/data/share/harness-fleet/schemas/candidate-output-v1.schema.json +123 -0
- harness_fleet-0.4.0.data/data/share/harness-fleet/schemas/input-item-v1.schema.json +77 -0
- harness_fleet-0.4.0.data/data/share/harness-fleet/schemas/output-v2.schema.json +148 -0
- harness_fleet-0.4.0.data/data/share/harness-fleet/schemas/packet-v2.schema.json +738 -0
- harness_fleet-0.4.0.data/data/share/harness-fleet/schemas/task-v1.schema.json +139 -0
- harness_fleet-0.4.0.dist-info/METADATA +591 -0
- harness_fleet-0.4.0.dist-info/RECORD +74 -0
- harness_fleet-0.4.0.dist-info/WHEEL +5 -0
- harness_fleet-0.4.0.dist-info/entry_points.txt +2 -0
- harness_fleet-0.4.0.dist-info/licenses/LICENSE +21 -0
- harness_fleet-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Harness Fleet: Coordinated CLI-harness worker fleet with closed fields and exact source evidence."""
|
|
2
|
+
__version__ = "0.4.0"
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Iterable, Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .catalog import RouteCatalog
|
|
9
|
+
from .discover import fetch_text, run_discovery
|
|
10
|
+
from .engine import Engine
|
|
11
|
+
from .export import export_clean_packet
|
|
12
|
+
from .grounding import verify_grounding
|
|
13
|
+
from .input_data import iter_input_items, load_input_items
|
|
14
|
+
from .models import (
|
|
15
|
+
CleanPacket,
|
|
16
|
+
ExtractedItem,
|
|
17
|
+
InputItem,
|
|
18
|
+
ModelOutput,
|
|
19
|
+
QuoteRef,
|
|
20
|
+
TaskSpec,
|
|
21
|
+
)
|
|
22
|
+
from .packer import pack_items
|
|
23
|
+
from .profile import IdealCompanyProfile
|
|
24
|
+
from .slicer import slice_document
|
|
25
|
+
from .store import HarnessStore
|
|
26
|
+
from .task import load_task_spec
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_packet(packet_path: str) -> dict:
|
|
30
|
+
"""Convenience helper for downstream trusted applications to safely load a clean packet."""
|
|
31
|
+
p = Path(packet_path).resolve()
|
|
32
|
+
if not p.exists():
|
|
33
|
+
raise FileNotFoundError(f"Packet file not found: {packet_path}")
|
|
34
|
+
return CleanPacket.model_validate_json(p.read_text(encoding="utf-8")).model_dump(mode="json", by_alias=True)
|
|
35
|
+
|
|
36
|
+
def process(
|
|
37
|
+
task: str | TaskSpec | Path,
|
|
38
|
+
input: str | Path | Iterable[InputItem | dict[str, Any]],
|
|
39
|
+
run_id: str | None = None,
|
|
40
|
+
concurrency: int = 4,
|
|
41
|
+
max_attempts: int = 300,
|
|
42
|
+
output: str | Path | None = None,
|
|
43
|
+
policy=None,
|
|
44
|
+
profile: IdealCompanyProfile | str | Path | None = None,
|
|
45
|
+
db: str | Path | None = None,
|
|
46
|
+
use_active_profile: bool = False,
|
|
47
|
+
**load_kwargs,
|
|
48
|
+
) -> dict:
|
|
49
|
+
"""High-level Python SDK: `harness_fleet.process(task, input, ...) -> packet`.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
import harness_fleet
|
|
53
|
+
packet = harness_fleet.process("my-task", "data.csv", concurrency=8)
|
|
54
|
+
df = packet["records"] # or use export helpers
|
|
55
|
+
|
|
56
|
+
Supports local paths (.csv/.jsonl/.json/.txt/.html/.pdf), lists of InputItem,
|
|
57
|
+
or pandas DataFrames (if installed) via `input=df`.
|
|
58
|
+
Profiles are opt-in: pass `profile=...` or set `use_active_profile=True`.
|
|
59
|
+
"""
|
|
60
|
+
import time as _time
|
|
61
|
+
|
|
62
|
+
# Resolve task
|
|
63
|
+
from pathlib import Path as _P # noqa: N814
|
|
64
|
+
|
|
65
|
+
store = HarnessStore(_P(db)) if db is not None else HarnessStore()
|
|
66
|
+
if isinstance(task, TaskSpec):
|
|
67
|
+
spec = task
|
|
68
|
+
store.register_task(spec)
|
|
69
|
+
elif isinstance(task, (str, _P)) and _P(str(task)).is_file():
|
|
70
|
+
spec = load_task_spec(str(task))
|
|
71
|
+
store.register_task(spec)
|
|
72
|
+
else:
|
|
73
|
+
spec = store.get_task(str(task))
|
|
74
|
+
|
|
75
|
+
# Resolve input items (DataFrame support optional)
|
|
76
|
+
items: Any
|
|
77
|
+
raw_items_factory = None
|
|
78
|
+
input_path_str = str(input) if isinstance(input, (str, _P)) else "python-sdk"
|
|
79
|
+
if isinstance(input, list):
|
|
80
|
+
# Keep caller-provided lists lazy through the packer; it validates each
|
|
81
|
+
# record at the same trust boundary as file-backed input.
|
|
82
|
+
items = input
|
|
83
|
+
elif isinstance(input, (str, _P)) and _P(str(input)).is_file():
|
|
84
|
+
source = _P(str(input))
|
|
85
|
+
input_options = dict(load_kwargs)
|
|
86
|
+
def raw_items_factory():
|
|
87
|
+
return iter_input_items(source, **input_options)
|
|
88
|
+
items = raw_items_factory()
|
|
89
|
+
else:
|
|
90
|
+
# Try DataFrame-like (has to_dict)
|
|
91
|
+
try:
|
|
92
|
+
if hasattr(input, "to_dict") and hasattr(input, "columns"):
|
|
93
|
+
# pandas DataFrame: itertuples avoids creating a second full
|
|
94
|
+
# list of row dictionaries. The fallback keeps support for
|
|
95
|
+
# DataFrame-like objects that only expose to_dict().
|
|
96
|
+
def dataframe_items():
|
|
97
|
+
columns = list(input.columns)
|
|
98
|
+
if hasattr(input, "itertuples"):
|
|
99
|
+
rows = (
|
|
100
|
+
dict(zip(columns, values, strict=False))
|
|
101
|
+
for values in input.itertuples(index=False, name=None)
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
rows = iter(input.to_dict(orient="records"))
|
|
105
|
+
for idx, rec in enumerate(rows):
|
|
106
|
+
# Prefer explicit id column
|
|
107
|
+
cand_id = rec.get("item_id") or rec.get("id") or f"row_{idx}"
|
|
108
|
+
txt = rec.get("text") or rec.get("body") or rec.get("content") or rec.get("description") or ""
|
|
109
|
+
if not txt:
|
|
110
|
+
# fallback: first stringifiable column value
|
|
111
|
+
for v in rec.values():
|
|
112
|
+
if isinstance(v, str) and len(v.strip()) > 20:
|
|
113
|
+
txt = v
|
|
114
|
+
break
|
|
115
|
+
yield InputItem(
|
|
116
|
+
item_id=str(cand_id),
|
|
117
|
+
text=str(txt),
|
|
118
|
+
metadata={k: v for k, v in rec.items() if k not in ("item_id", "id", "text", "body", "content")},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
raw_items_factory = dataframe_items
|
|
122
|
+
items = raw_items_factory()
|
|
123
|
+
elif isinstance(input, Mapping):
|
|
124
|
+
raise TypeError("input mappings are not records; pass an iterable of InputItem/dict records")
|
|
125
|
+
elif isinstance(input, Iterable):
|
|
126
|
+
# Generators and other one-shot iterables are consumed once by
|
|
127
|
+
# Engine, which keeps ingestion bounded without requiring a
|
|
128
|
+
# second pass or a full in-memory copy.
|
|
129
|
+
items = input
|
|
130
|
+
else:
|
|
131
|
+
raise TypeError("unsupported input type")
|
|
132
|
+
except Exception as e:
|
|
133
|
+
raise ValueError(f"Unable to resolve input '{input}': {e}") from e
|
|
134
|
+
|
|
135
|
+
rid = run_id or f"sdk-{_time.time_ns()}-{uuid.uuid4().hex[:8]}"
|
|
136
|
+
out_path = _P(output) if output is not None else _P(f"runs/{rid}/clean_packet.json")
|
|
137
|
+
selected_profile = None
|
|
138
|
+
profile_revision_id = None
|
|
139
|
+
if profile is not None:
|
|
140
|
+
selected_profile = (
|
|
141
|
+
profile
|
|
142
|
+
if isinstance(profile, IdealCompanyProfile)
|
|
143
|
+
else IdealCompanyProfile.load(profile)
|
|
144
|
+
)
|
|
145
|
+
profile_revision_id = store.save_profile(selected_profile)
|
|
146
|
+
elif use_active_profile:
|
|
147
|
+
profile_revision_id = store.active_profile_revision_id("ideal_company")
|
|
148
|
+
if selected_profile is None and profile_revision_id:
|
|
149
|
+
selected_profile = store.load_profile("ideal_company")
|
|
150
|
+
eng = Engine(task=spec, store=store, policy=policy, profile=selected_profile)
|
|
151
|
+
return eng.run_campaign(
|
|
152
|
+
raw_items=items,
|
|
153
|
+
run_id=rid,
|
|
154
|
+
input_path=input_path_str,
|
|
155
|
+
concurrency=concurrency,
|
|
156
|
+
max_attempts=max_attempts,
|
|
157
|
+
output_packet_path=out_path,
|
|
158
|
+
profile_revision_id=profile_revision_id,
|
|
159
|
+
profile=selected_profile,
|
|
160
|
+
raw_items_factory=raw_items_factory,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
__all__ = [
|
|
164
|
+
"load_task_spec",
|
|
165
|
+
"IdealCompanyProfile",
|
|
166
|
+
"Engine",
|
|
167
|
+
"RouteCatalog",
|
|
168
|
+
"verify_grounding",
|
|
169
|
+
"CleanPacket",
|
|
170
|
+
"ExtractedItem",
|
|
171
|
+
"InputItem",
|
|
172
|
+
"ModelOutput",
|
|
173
|
+
"QuoteRef",
|
|
174
|
+
"TaskSpec",
|
|
175
|
+
"HarnessStore",
|
|
176
|
+
"load_input_items",
|
|
177
|
+
"iter_input_items",
|
|
178
|
+
"fetch_text",
|
|
179
|
+
"run_discovery",
|
|
180
|
+
"slice_document",
|
|
181
|
+
"pack_items",
|
|
182
|
+
"export_clean_packet",
|
|
183
|
+
"read_packet",
|
|
184
|
+
"process",
|
|
185
|
+
"__version__",
|
|
186
|
+
]
|
harness_fleet/bundler.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Multi-source entity bundler.
|
|
2
|
+
|
|
3
|
+
Combines raw evidence hits gathered across web crawls, case studies, vendor partner
|
|
4
|
+
registries, B2B review platforms, community/social footprints, and ATS job postings
|
|
5
|
+
into consolidated, section-tagged composite dossiers per canonical entity.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import re
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Iterable, Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
from .models import InputItem
|
|
18
|
+
|
|
19
|
+
# Known directory & platform domains mapped to source categories
|
|
20
|
+
REGISTRY_DOMAINS = (
|
|
21
|
+
"partners.amazonaws.com",
|
|
22
|
+
"snowflake.com",
|
|
23
|
+
"appsource.microsoft.com",
|
|
24
|
+
"cloud.google.com",
|
|
25
|
+
"datadoghq.com",
|
|
26
|
+
"ecosystem.hubspot.com",
|
|
27
|
+
"salesforce.com",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
REVIEW_DOMAINS = (
|
|
31
|
+
"clutch.co",
|
|
32
|
+
"g2.com",
|
|
33
|
+
"goodfirms.co",
|
|
34
|
+
"themanifest.com",
|
|
35
|
+
"upcity.com",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
COMMUNITY_DOMAINS = (
|
|
39
|
+
"github.com",
|
|
40
|
+
"youtube.com",
|
|
41
|
+
"substack.com",
|
|
42
|
+
"medium.com",
|
|
43
|
+
"dev.to",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
ATS_DOMAINS = (
|
|
47
|
+
"jobs.ashbyhq.com",
|
|
48
|
+
"boards.greenhouse.io",
|
|
49
|
+
"jobs.lever.co",
|
|
50
|
+
"apply.workable.com",
|
|
51
|
+
"jobs.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
CASE_STUDY_PATH_RE = re.compile(r"/(case-stud|work|customers?|success-stor|clients|portfolio)", re.IGNORECASE)
|
|
55
|
+
PRACTICE_PATH_RE = re.compile(r"/(services?|solutions?|practices?|about|partners?|consulting)", re.IGNORECASE)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def canonicalize_entity_id(identifier_or_url: str) -> str:
|
|
59
|
+
"""Extract clean, canonical domain/entity id from a URL or raw identifier."""
|
|
60
|
+
raw = (identifier_or_url or "").strip().lower()
|
|
61
|
+
if not raw:
|
|
62
|
+
return "unknown_entity"
|
|
63
|
+
|
|
64
|
+
# If it contains a URL scheme or looks like a URL
|
|
65
|
+
if "://" in raw or "/" in raw:
|
|
66
|
+
parsed = urlparse(raw if "://" in raw else f"https://{raw}")
|
|
67
|
+
host = parsed.netloc.lower().strip()
|
|
68
|
+
path = parsed.path.strip("/")
|
|
69
|
+
|
|
70
|
+
# Strip common prefixes
|
|
71
|
+
if host.startswith("www."):
|
|
72
|
+
host = host[4:]
|
|
73
|
+
|
|
74
|
+
# Handle ATS URLs where company slug is the first path segment
|
|
75
|
+
if any(ats in host for ats in ("ashbyhq.com", "greenhouse.io", "lever.co", "workable.com")):
|
|
76
|
+
parts = [p for p in path.split("/") if p]
|
|
77
|
+
if parts:
|
|
78
|
+
slug = parts[0].replace("-", "_")
|
|
79
|
+
# Append .com if slug looks like a domain name
|
|
80
|
+
return f"{slug}.com" if "." not in slug else slug
|
|
81
|
+
|
|
82
|
+
# Handle vendor registry paths: e.g. partners.amazonaws.com/partners/trace3
|
|
83
|
+
if "partners.amazonaws.com" in host or "snowflake.com" in host:
|
|
84
|
+
parts = [p for p in path.split("/") if p and p not in ("partners", "en-us", "marketplace")]
|
|
85
|
+
if parts:
|
|
86
|
+
slug = parts[-1].replace("-", "_")
|
|
87
|
+
return f"{slug}.com" if "." not in slug else slug
|
|
88
|
+
|
|
89
|
+
# Handle Clutch/G2 profiles: clutch.co/profile/trace3
|
|
90
|
+
if any(rev in host for rev in ("clutch.co", "g2.com")):
|
|
91
|
+
parts = [p for p in path.split("/") if p and p not in ("profile", "it-services", "products")]
|
|
92
|
+
if parts:
|
|
93
|
+
slug = parts[-1].replace("-", "_")
|
|
94
|
+
return f"{slug}.com" if "." not in slug else slug
|
|
95
|
+
|
|
96
|
+
if host:
|
|
97
|
+
return host
|
|
98
|
+
|
|
99
|
+
# Plain text identifier
|
|
100
|
+
cleaned = re.sub(r"[^a-z0-9_.-]+", "_", raw).strip("_.")
|
|
101
|
+
return cleaned or "unknown_entity"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def classify_source_category(source_uri: str, entity_id: str = "") -> str:
|
|
105
|
+
"""Classify a source URL into one of the 6 canonical partner evidence categories."""
|
|
106
|
+
uri = (source_uri or "").strip().lower()
|
|
107
|
+
if not uri:
|
|
108
|
+
return "general_web"
|
|
109
|
+
|
|
110
|
+
# 1. Vendor registries
|
|
111
|
+
if any(d in uri for d in REGISTRY_DOMAINS) and "partner" in uri:
|
|
112
|
+
return "vendor_registry"
|
|
113
|
+
|
|
114
|
+
# 2. Review and audit platforms
|
|
115
|
+
if any(d in uri for d in REVIEW_DOMAINS):
|
|
116
|
+
return "b2b_directory_audit"
|
|
117
|
+
|
|
118
|
+
# 3. Community and social platforms
|
|
119
|
+
if any(d in uri for d in COMMUNITY_DOMAINS):
|
|
120
|
+
return "community_and_social"
|
|
121
|
+
|
|
122
|
+
# 4. ATS / Hiring requisitions
|
|
123
|
+
if any(ats in uri for ats in ATS_DOMAINS):
|
|
124
|
+
return "ats_requisitions"
|
|
125
|
+
|
|
126
|
+
# 5. First-party case studies
|
|
127
|
+
if CASE_STUDY_PATH_RE.search(uri):
|
|
128
|
+
return "first_party_case_study"
|
|
129
|
+
|
|
130
|
+
# 6. First-party practices and services
|
|
131
|
+
if PRACTICE_PATH_RE.search(uri):
|
|
132
|
+
return "first_party_practice"
|
|
133
|
+
|
|
134
|
+
# Fallback to first-party if host matches entity domain
|
|
135
|
+
if entity_id and entity_id in uri:
|
|
136
|
+
return "first_party_practice"
|
|
137
|
+
|
|
138
|
+
return "general_web"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def bundle_records(
|
|
142
|
+
records: Iterable[InputItem | dict[str, Any]],
|
|
143
|
+
*,
|
|
144
|
+
min_sources: int = 1,
|
|
145
|
+
min_categories: int = 1,
|
|
146
|
+
) -> list[InputItem]:
|
|
147
|
+
"""Group multi-channel evidence hits by canonical entity into composite dossiers."""
|
|
148
|
+
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
149
|
+
|
|
150
|
+
for item in records:
|
|
151
|
+
if isinstance(item, InputItem):
|
|
152
|
+
raw_id = item.item_id
|
|
153
|
+
text = item.text
|
|
154
|
+
uri = item.source_uri or ""
|
|
155
|
+
metadata = dict(item.metadata or {})
|
|
156
|
+
else:
|
|
157
|
+
raw_id = str(item.get("item_id") or item.get("domain") or item.get("url") or "")
|
|
158
|
+
text = str(item.get("text") or item.get("research") or "")
|
|
159
|
+
uri = str(item.get("source_uri") or item.get("url") or "")
|
|
160
|
+
metadata = dict(item.get("metadata") or {})
|
|
161
|
+
|
|
162
|
+
canonical_id = canonicalize_entity_id(raw_id or uri)
|
|
163
|
+
category = metadata.get("source_category") or classify_source_category(uri, canonical_id)
|
|
164
|
+
|
|
165
|
+
grouped[canonical_id].append({
|
|
166
|
+
"text": text.strip(),
|
|
167
|
+
"source_uri": uri,
|
|
168
|
+
"source_category": category,
|
|
169
|
+
"metadata": metadata,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
bundled_items: list[InputItem] = []
|
|
173
|
+
|
|
174
|
+
for entity_id, hits in sorted(grouped.items()):
|
|
175
|
+
if len(hits) < min_sources:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
categories = sorted({h["source_category"] for h in hits if h["source_category"]})
|
|
179
|
+
if len(categories) < min_categories:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# Format composite document
|
|
183
|
+
sections: list[str] = [f"# Multi-Source Evidence Dossier: {entity_id}\n"]
|
|
184
|
+
all_uris: list[str] = []
|
|
185
|
+
|
|
186
|
+
for i, hit in enumerate(hits, 1):
|
|
187
|
+
cat = hit["source_category"].upper()
|
|
188
|
+
uri_text = hit["source_uri"] or f"source_{i}"
|
|
189
|
+
all_uris.append(uri_text)
|
|
190
|
+
sections.append(
|
|
191
|
+
f"=== SECTION: {cat} (URI: {uri_text}) ===\n"
|
|
192
|
+
f"{hit['text']}\n"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
composite_text = "\n".join(sections).strip()
|
|
196
|
+
|
|
197
|
+
bundled_items.append(
|
|
198
|
+
InputItem(
|
|
199
|
+
item_id=entity_id,
|
|
200
|
+
text=composite_text,
|
|
201
|
+
source_uri=all_uris[0] if all_uris else "",
|
|
202
|
+
title=f"Partner Dossier: {entity_id}",
|
|
203
|
+
metadata={
|
|
204
|
+
"entity": entity_id,
|
|
205
|
+
"source_count": len(hits),
|
|
206
|
+
"source_categories": categories,
|
|
207
|
+
"category_count": len(categories),
|
|
208
|
+
"source_uris": all_uris,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return bundled_items
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def load_and_bundle(
|
|
217
|
+
input_path: str | Path,
|
|
218
|
+
*,
|
|
219
|
+
id_column: str | None = None,
|
|
220
|
+
text_column: str | None = None,
|
|
221
|
+
uri_column: str | None = None,
|
|
222
|
+
min_sources: int = 1,
|
|
223
|
+
min_categories: int = 1,
|
|
224
|
+
) -> list[InputItem]:
|
|
225
|
+
"""Load records from CSV or JSONL and bundle by canonical entity."""
|
|
226
|
+
from .input_data import load_input_items
|
|
227
|
+
|
|
228
|
+
raw_items = load_input_items(
|
|
229
|
+
input_path,
|
|
230
|
+
id_column=id_column,
|
|
231
|
+
text_column=text_column,
|
|
232
|
+
uri_column=uri_column,
|
|
233
|
+
)
|
|
234
|
+
return bundle_records(raw_items, min_sources=min_sources, min_categories=min_categories)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def export_bundled_csv(
|
|
238
|
+
bundled_items: Sequence[InputItem],
|
|
239
|
+
output_path: str | Path,
|
|
240
|
+
) -> Path:
|
|
241
|
+
"""Export bundled items to a CSV file ready for harness-fleet run."""
|
|
242
|
+
out = Path(output_path).expanduser().resolve()
|
|
243
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
|
|
245
|
+
with out.open("w", encoding="utf-8", newline="") as f:
|
|
246
|
+
writer = csv.writer(f)
|
|
247
|
+
writer.writerow(["item_id", "text", "source_uri", "source_count", "source_categories"])
|
|
248
|
+
for item in bundled_items:
|
|
249
|
+
meta = item.metadata or {}
|
|
250
|
+
writer.writerow([
|
|
251
|
+
item.item_id,
|
|
252
|
+
item.text,
|
|
253
|
+
item.source_uri or "",
|
|
254
|
+
meta.get("source_count", 1),
|
|
255
|
+
",".join(meta.get("source_categories", [])),
|
|
256
|
+
])
|
|
257
|
+
return out
|