Skip to content

Commit 956c0ed

Browse files
Publish the privacy-safe lead agent
0 parents  commit 956c0ed

11 files changed

Lines changed: 484 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: "3.11"
18+
cache: pip
19+
- run: pip install -e '.[dev]'
20+
- run: pytest -q

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
__pycache__/
2+
.pytest_cache/
3+
.venv/
4+
*.egg-info/
5+
outputs/
6+
.env
7+
.DS_Store

CONTRIBUTING.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Contributing
2+
3+
Open an issue describing the change and its ethical-use implications. Keep examples fictional, tests deterministic, and source provenance visible. Never commit personal contact data, credentials, scraped datasets, or generated outreach lists.
4+
5+
Before opening a pull request, run:
6+
7+
```bash
8+
pytest -q
9+
```

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Emiliano Ruiz Gomez
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# CreatorStack Lead Agent
2+
3+
A reproducible prospect-research pipeline that turns supplied, reviewable public-source signals into CSV and Markdown artifacts while keeping production qualification policy private.
4+
5+
This repository is a sanitized demonstration. Every included person and organization is fictional, no contact details are shipped, and the workflow stops before outreach.
6+
7+
## Why it exists
8+
9+
Lead research often mixes discovery, unsupported inference, scoring, and outreach into one opaque automation. CreatorStack Lead Agent separates those concerns:
10+
11+
1. Load documented public-source records or fixtures.
12+
2. Validate every record and its source URL.
13+
3. Pass validated evidence through a replaceable `ScoringPolicy` boundary.
14+
4. Optionally use a local model to summarize supplied evidence, never to infer qualification or intent.
15+
5. Export evidence and source links for human review.
16+
17+
## Quick start
18+
19+
Requires Python 3.11 or newer.
20+
21+
```bash
22+
python -m venv .venv
23+
source .venv/bin/activate
24+
pip install -e '.[dev]'
25+
26+
lead-agent \
27+
--fixture examples/fixtures/sample-prospects.json \
28+
--output outputs/sample
29+
```
30+
31+
The command creates `prospects.csv` and `prospects.md`. Run `pytest -q` to validate the same deterministic path used in CI.
32+
33+
## Optional local-model explanation
34+
35+
With Ollama running locally:
36+
37+
```bash
38+
lead-agent \
39+
--fixture examples/fixtures/sample-prospects.json \
40+
--output outputs/with-explanations \
41+
--ollama-model qwen2.5:7b
42+
```
43+
44+
The model receives only the visible record, review status, and evidence boundary. Its summary is supplementary and does not qualify the prospect.
45+
46+
## Private policy boundary
47+
48+
The public `PublicDemoPolicy` checks only whether labeled evidence exists and routes every complete record to human review. It deliberately contains no production weights, role values, purchase-intent inference, or competitive qualification heuristics. A private implementation can satisfy the `ScoringPolicy` protocol without changing fixture validation or output contracts.
49+
50+
## Ethical-use boundary
51+
52+
- Use public business information, consented data, or fictional fixtures.
53+
- Do not collect sensitive personal traits or bypass access controls.
54+
- Preserve source URLs and review the evidence manually.
55+
- Respect site terms, privacy law, opt-outs, and reasonable request rates.
56+
- Do not automate messages from this project. Outputs are research drafts only.
57+
58+
The sample is not a claim of real prospect quality or campaign performance. It demonstrates contracts, provenance, a private-policy interface, reproducibility, and human approval boundaries.
59+
60+
## Architecture
61+
62+
```text
63+
supplied fixture records → validation → private policy boundary → review records
64+
└─ optional local summary
65+
review records → CSV + Markdown → human review → separate approved workflow
66+
```
67+
68+
## License
69+
70+
[MIT](LICENSE). See [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a new data source.

assets/social-preview.svg

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[
2+
{
3+
"name": "Avery Chen",
4+
"organization": "Northstar Studio",
5+
"role": "Founder",
6+
"source_url": "https://example.com/northstar-studio",
7+
"signals": ["launch", "manual content workflow"],
8+
"notes": "Fictional fixture for demonstrating a transparent scoring contract."
9+
},
10+
{
11+
"name": "Samira Laurent",
12+
"organization": "Common Thread Foods",
13+
"role": "Marketing Director",
14+
"source_url": "https://example.com/common-thread-foods",
15+
"signals": ["expanding", "content"],
16+
"notes": "Fictional fixture; no contact details or real-person data."
17+
},
18+
{
19+
"name": "Mateo Silva",
20+
"organization": "Fieldwork Collective",
21+
"role": "Operations Lead",
22+
"source_url": "https://example.com/fieldwork-collective",
23+
"signals": ["manual reporting"],
24+
"notes": "Fictional lower-confidence record retained to exercise ranking."
25+
}
26+
]

lead_agent.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Fixture-first prospect research with a private scoring-policy boundary."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import csv
7+
import json
8+
import urllib.request
9+
from dataclasses import asdict, dataclass
10+
from pathlib import Path
11+
from typing import Iterable, Protocol
12+
13+
14+
@dataclass(frozen=True)
15+
class Prospect:
16+
name: str
17+
organization: str
18+
role: str
19+
source_url: str
20+
signals: tuple[str, ...]
21+
notes: str = ""
22+
23+
24+
@dataclass(frozen=True)
25+
class ReviewResult:
26+
prospect: Prospect
27+
status: str
28+
reasons: tuple[str, ...]
29+
30+
31+
class ScoringPolicy(Protocol):
32+
"""Private implementations can provide weighted or model-assisted qualification."""
33+
34+
def evaluate(self, prospect: Prospect) -> ReviewResult: ...
35+
36+
37+
class PublicDemoPolicy:
38+
"""Validate evidence completeness without publishing qualification weights."""
39+
40+
def evaluate(self, prospect: Prospect) -> ReviewResult:
41+
if not prospect.signals:
42+
return ReviewResult(
43+
prospect=prospect,
44+
status="needs_evidence",
45+
reasons=("no labeled public business signal was supplied",),
46+
)
47+
return ReviewResult(
48+
prospect=prospect,
49+
status="human_review",
50+
reasons=("public source recorded", "labeled business signals require human review"),
51+
)
52+
53+
54+
def load_fixture_prospects(path: str | Path) -> list[Prospect]:
55+
"""Load fictional or consented records from a documented JSON fixture."""
56+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
57+
if not isinstance(payload, list):
58+
raise ValueError("fixture must be a JSON array")
59+
prospects: list[Prospect] = []
60+
for index, item in enumerate(payload):
61+
required = {"name", "organization", "role", "source_url", "signals"}
62+
if not required.issubset(item):
63+
raise ValueError(f"fixture record {index} is missing required fields")
64+
if not str(item["source_url"]).startswith(("https://", "http://")):
65+
raise ValueError(f"fixture record {index} requires a public source URL")
66+
prospects.append(
67+
Prospect(
68+
name=str(item["name"]),
69+
organization=str(item["organization"]),
70+
role=str(item["role"]),
71+
source_url=str(item["source_url"]),
72+
signals=tuple(str(signal) for signal in item["signals"]),
73+
notes=str(item.get("notes", "")),
74+
)
75+
)
76+
return prospects
77+
78+
79+
def evaluate(
80+
prospects: Iterable[Prospect],
81+
policy: ScoringPolicy | None = None,
82+
) -> list[ReviewResult]:
83+
active_policy = policy or PublicDemoPolicy()
84+
return sorted(
85+
(active_policy.evaluate(item) for item in prospects),
86+
key=lambda item: (item.status != "human_review", item.prospect.organization),
87+
)
88+
89+
90+
def ollama_explanation(item: ReviewResult, model: str, host: str) -> str:
91+
"""Optionally ask a local model to summarize visible evidence, not qualify the lead."""
92+
prompt = (
93+
"Summarize this supplied public-business evidence in two factual sentences. "
94+
"Do not infer sensitive traits, purchase intent, or missing facts. "
95+
f"Record: {json.dumps(asdict(item.prospect))}. "
96+
f"Review status: {item.status}; reasons: {list(item.reasons)}"
97+
)
98+
body = json.dumps({"model": model, "prompt": prompt, "stream": False}).encode()
99+
request = urllib.request.Request(
100+
f"{host.rstrip('/')}/api/generate",
101+
data=body,
102+
headers={"Content-Type": "application/json"},
103+
)
104+
with urllib.request.urlopen(request, timeout=60) as response:
105+
return str(json.load(response).get("response", "")).strip()
106+
107+
108+
def write_outputs(
109+
items: list[ReviewResult],
110+
output_dir: str | Path,
111+
explanations: dict[str, str] | None = None,
112+
) -> None:
113+
destination = Path(output_dir)
114+
destination.mkdir(parents=True, exist_ok=True)
115+
explanations = explanations or {}
116+
with (destination / "prospects.csv").open("w", newline="", encoding="utf-8") as handle:
117+
writer = csv.DictWriter(
118+
handle,
119+
fieldnames=["organization", "name", "role", "review_status", "reasons", "source_url"],
120+
)
121+
writer.writeheader()
122+
for item in items:
123+
writer.writerow(
124+
{
125+
"organization": item.prospect.organization,
126+
"name": item.prospect.name,
127+
"role": item.prospect.role,
128+
"review_status": item.status,
129+
"reasons": "; ".join(item.reasons),
130+
"source_url": item.prospect.source_url,
131+
}
132+
)
133+
lines = [
134+
"# Prospect review",
135+
"",
136+
"> Research output only. Human approval is required before qualification or outreach.",
137+
"",
138+
]
139+
for item in items:
140+
lines.extend(
141+
[
142+
f"## {item.prospect.organization}{item.status}",
143+
"",
144+
f"**Contact:** {item.prospect.name}, {item.prospect.role}",
145+
f"**Public source:** {item.prospect.source_url}",
146+
f"**Evidence boundary:** {'; '.join(item.reasons)}",
147+
]
148+
)
149+
if explanations.get(item.prospect.organization):
150+
lines.append(f"**Local-model summary:** {explanations[item.prospect.organization]}")
151+
lines.append("")
152+
(destination / "prospects.md").write_text("\n".join(lines), encoding="utf-8")
153+
154+
155+
def build_parser() -> argparse.ArgumentParser:
156+
parser = argparse.ArgumentParser(description=__doc__)
157+
parser.add_argument(
158+
"--fixture",
159+
required=True,
160+
help="JSON file containing fictional or consented public-source records",
161+
)
162+
parser.add_argument("--output", default="outputs/sample", help="Output directory")
163+
parser.add_argument("--ollama-model", help="Optional local model used only to summarize supplied evidence")
164+
parser.add_argument("--ollama-host", default="http://127.0.0.1:11434")
165+
return parser
166+
167+
168+
def main(argv: list[str] | None = None) -> int:
169+
args = build_parser().parse_args(argv)
170+
items = evaluate(load_fixture_prospects(args.fixture))
171+
explanations: dict[str, str] = {}
172+
if args.ollama_model:
173+
for item in items:
174+
explanations[item.prospect.organization] = ollama_explanation(
175+
item, args.ollama_model, args.ollama_host
176+
)
177+
write_outputs(items, args.output, explanations)
178+
print(f"Wrote {len(items)} review records to {args.output}")
179+
return 0
180+
181+
182+
if __name__ == "__main__":
183+
raise SystemExit(main())

pyproject.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[project]
2+
name = "creatorstack-lead-agent"
3+
version = "0.1.0"
4+
description = "Reproducible, fixture-first prospect research and scoring pipeline."
5+
requires-python = ">=3.11"
6+
dependencies = []
7+
8+
[project.optional-dependencies]
9+
dev = ["pytest>=8"]
10+
11+
[project.scripts]
12+
lead-agent = "lead_agent:main"
13+
14+
[build-system]
15+
requires = ["setuptools>=69"]
16+
build-backend = "setuptools.build_meta"
17+
18+
[tool.setuptools]
19+
py-modules = ["lead_agent"]
20+
21+
[tool.pytest.ini_options]
22+
testpaths = ["tests"]
23+
pythonpath = ["."]

0 commit comments

Comments
 (0)