Skip to content

Commit 06e39e4

Browse files
committed
Add LLMGate lead scouting helper
1 parent 44a9031 commit 06e39e4

3 files changed

Lines changed: 307 additions & 0 deletions

File tree

docs/llm-gateway-fallback.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,23 @@ Use a stronger model for high-value reasoning:
3636
python3 scripts/llm_gateway.py chat --model gpt-5.5 --prompt "Draft a concise audit report executive summary."
3737
```
3838

39+
Scout public GitHub leads and let LLMGate score the shortlist:
40+
41+
```bash
42+
python3 scripts/scout_leads.py --per-query 8 --max-candidates 25 --output local/lead-work/next-leads.md
43+
```
44+
45+
Preview raw public candidates without using LLMGate:
46+
47+
```bash
48+
python3 scripts/scout_leads.py --no-llm --per-query 3
49+
```
50+
3951
## Policy
4052

4153
- Do not commit gateway credentials.
4254
- Do not paste private user data, raw conversations, or secrets into gateway prompts.
4355
- Prefer local deterministic scripts for scanning and validation.
4456
- Use the gateway for drafting, summarization, and low-risk analysis when quota is tight.
57+
- Use `scripts/scout_leads.py` for lead scoring only; it must not send outreach.
4558
- Keep outbound outreach inside `docs/autonomous-outreach-policy.md`.

scripts/scout_leads.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
#!/usr/bin/env python3
2+
"""Scout public GitHub leads and offload lead scoring to LLMGate.
3+
4+
This script is intentionally read-only with respect to public platforms: it
5+
searches public GitHub issues, filters previously-contacted URLs from
6+
`leads/sent/`, and prepares a ranked action queue. It never posts outreach.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import json
13+
import subprocess
14+
import sys
15+
from dataclasses import dataclass
16+
from pathlib import Path
17+
from typing import Any
18+
19+
import llm_gateway
20+
21+
22+
DEFAULT_QUERIES = [
23+
'"pre-launch security review"',
24+
'"pre-launch security audit"',
25+
'"security hardening" "help wanted"',
26+
'"repo security audit"',
27+
'"exposed secret" "help wanted"',
28+
'"CSP audit"',
29+
'"dependency audit" "security"',
30+
]
31+
32+
SENSITIVE_CONTEXT_HINTS = [
33+
"child",
34+
"children",
35+
"education",
36+
"medical",
37+
"patient",
38+
"health",
39+
"therapy",
40+
"minor",
41+
"student",
42+
"school",
43+
"fleet",
44+
"location",
45+
"tracking",
46+
]
47+
48+
MAX_BODY_CHARS = 900
49+
50+
51+
@dataclass(frozen=True)
52+
class Candidate:
53+
repo: str
54+
number: int
55+
title: str
56+
url: str
57+
updated_at: str
58+
labels: list[str]
59+
body: str
60+
query: str
61+
62+
def compact(self) -> dict[str, Any]:
63+
return {
64+
"repo": self.repo,
65+
"number": self.number,
66+
"title": self.title,
67+
"url": self.url,
68+
"updated_at": self.updated_at,
69+
"labels": self.labels,
70+
"query": self.query,
71+
"body_excerpt": trim_text(self.body, MAX_BODY_CHARS),
72+
}
73+
74+
75+
def trim_text(value: str, max_chars: int) -> str:
76+
normalized = " ".join(value.split())
77+
if len(normalized) <= max_chars:
78+
return normalized
79+
return normalized[: max_chars - 3].rstrip() + "..."
80+
81+
82+
def run_json(command: list[str]) -> Any:
83+
try:
84+
completed = subprocess.run(
85+
command,
86+
check=True,
87+
text=True,
88+
capture_output=True,
89+
)
90+
except FileNotFoundError as error:
91+
raise SystemExit(f"Missing required command: {command[0]}") from error
92+
except subprocess.CalledProcessError as error:
93+
detail = error.stderr.strip() or error.stdout.strip()
94+
raise SystemExit(f"Command failed: {' '.join(command)}\n{detail}") from error
95+
return json.loads(completed.stdout or "[]")
96+
97+
98+
def normalize_github_url(url: str) -> str:
99+
"""Normalize issue/PR/comment URLs to their public thread URL."""
100+
clean = url.rstrip(".,;")
101+
if "#" in clean:
102+
clean = clean.split("#", 1)[0]
103+
parts = clean.split("/")
104+
if len(parts) >= 7 and parts[2] == "github.com" and parts[5] in {"issues", "pull"}:
105+
return "/".join(parts[:7])
106+
return clean
107+
108+
109+
def repo_from_github_url(url: str) -> str | None:
110+
clean = normalize_github_url(url)
111+
parts = clean.split("/")
112+
if len(parts) >= 5 and parts[2] == "github.com":
113+
return f"{parts[3]}/{parts[4]}"
114+
return None
115+
116+
117+
def sent_urls(sent_dir: Path) -> set[str]:
118+
urls: set[str] = set()
119+
if not sent_dir.exists():
120+
return urls
121+
for path in sent_dir.glob("*.md"):
122+
text = path.read_text(encoding="utf-8", errors="replace")
123+
for token in text.replace("(", " ").replace(")", " ").split():
124+
if token.startswith("https://github.com/"):
125+
urls.add(normalize_github_url(token))
126+
return urls
127+
128+
129+
def sent_repos(sent_dir: Path) -> set[str]:
130+
return {repo for url in sent_urls(sent_dir) if (repo := repo_from_github_url(url))}
131+
132+
133+
def has_sensitive_hint(candidate: Candidate) -> bool:
134+
haystack = f"{candidate.repo} {candidate.title} {candidate.body}".lower()
135+
return any(hint in haystack for hint in SENSITIVE_CONTEXT_HINTS)
136+
137+
138+
def search_candidates(
139+
queries: list[str],
140+
per_query: int,
141+
sent: set[str],
142+
contacted_repos: set[str],
143+
) -> list[Candidate]:
144+
seen: set[str] = set()
145+
candidates: list[Candidate] = []
146+
for query in queries:
147+
data = run_json(
148+
[
149+
"gh",
150+
"search",
151+
"issues",
152+
query,
153+
"--state",
154+
"open",
155+
"--limit",
156+
str(per_query),
157+
"--json",
158+
"repository,title,url,number,updatedAt,labels,body",
159+
]
160+
)
161+
for item in data:
162+
url = item.get("url", "")
163+
normalized_url = normalize_github_url(url)
164+
if not normalized_url or normalized_url in seen or normalized_url in sent:
165+
continue
166+
labels = [label.get("name", "") for label in item.get("labels", []) if label.get("name")]
167+
repo = item.get("repository", {}).get("nameWithOwner", "")
168+
if repo in contacted_repos:
169+
continue
170+
candidate = Candidate(
171+
repo=repo,
172+
number=int(item.get("number") or 0),
173+
title=item.get("title", ""),
174+
url=normalized_url,
175+
updated_at=item.get("updatedAt", ""),
176+
labels=labels,
177+
body=item.get("body", ""),
178+
query=query,
179+
)
180+
seen.add(normalized_url)
181+
candidates.append(candidate)
182+
return candidates
183+
184+
185+
def build_prompt(candidates: list[Candidate], top_n: int) -> str:
186+
payload = [candidate.compact() for candidate in candidates]
187+
return (
188+
"You are scoring public GitHub leads for FreeCodex's lightweight "
189+
"Vibe/Agent Repo Safety Audit revenue campaign.\n\n"
190+
"Policy constraints:\n"
191+
"- Public repos/issues only.\n"
192+
"- No outreach to minors, medical patients, vulnerable users, or sensitive personal-data contexts.\n"
193+
"- No exploit, stealth, persistence, malware, or unauthorized offensive-security work.\n"
194+
"- Prefer explicit requests for security review, launch readiness, repo hygiene, CI, CSP, dependency audit, secret scan, or bounded PR fixes.\n"
195+
"- First action should create value: a small PR, mini-audit, or specific useful comment.\n"
196+
"- Do not recommend sending payment links in first contact.\n"
197+
"- All outbound still needs Codex review before posting.\n\n"
198+
f"Return the top {top_n} candidates as concise Markdown. For each include: "
199+
"rank, URL, fit score 1-10, risk level, why fit, first action, verification needed, and skip reason if not safe.\n\n"
200+
"Use the exact `url` value from the JSON. Do not rewrite issue URLs.\n\n"
201+
"Candidates JSON:\n"
202+
f"{json.dumps(payload, ensure_ascii=False, indent=2)}"
203+
)
204+
205+
206+
def score_with_llmgate(args: argparse.Namespace, candidates: list[Candidate]) -> str:
207+
config = llm_gateway.load_config(args.env)
208+
base_url, api_key = llm_gateway.require_config(config)
209+
model = args.model or config.get("LLMGATE_SCOUT_MODEL", "gemini-2.5-flash-lite")
210+
prompt = build_prompt(candidates[: args.max_candidates], args.top)
211+
data = llm_gateway.request_json(
212+
base_url=base_url,
213+
api_key=api_key,
214+
path="/chat/completions",
215+
method="POST",
216+
payload={
217+
"model": model,
218+
"messages": [{"role": "user", "content": prompt}],
219+
"temperature": 0.2,
220+
"max_tokens": args.max_tokens,
221+
},
222+
timeout=args.timeout,
223+
)
224+
choices = data.get("choices") or []
225+
if not choices:
226+
return json.dumps(data, indent=2, sort_keys=True)
227+
content = choices[0].get("message", {}).get("content", "")
228+
if isinstance(content, list):
229+
content = "\n".join(
230+
part.get("text", "") for part in content if isinstance(part, dict) and part.get("type") == "text"
231+
)
232+
return str(content).strip()
233+
234+
235+
def render_no_llm(candidates: list[Candidate]) -> str:
236+
return json.dumps([candidate.compact() for candidate in candidates], ensure_ascii=False, indent=2)
237+
238+
239+
def write_or_print(text: str, output: Path | None) -> None:
240+
if output is None:
241+
print(text)
242+
return
243+
output.parent.mkdir(parents=True, exist_ok=True)
244+
output.write_text(text.rstrip() + "\n", encoding="utf-8")
245+
print(f"Wrote lead scout report: {output}")
246+
247+
248+
def build_parser() -> argparse.ArgumentParser:
249+
parser = argparse.ArgumentParser(description="Scout public GitHub leads and score them with LLMGate")
250+
parser.add_argument("--query", action="append", dest="queries", help="GitHub issue search query; repeatable")
251+
parser.add_argument("--per-query", type=int, default=8, help="results per query")
252+
parser.add_argument("--max-candidates", type=int, default=25, help="max candidates sent to LLMGate")
253+
parser.add_argument("--top", type=int, default=5, help="number of ranked leads requested")
254+
parser.add_argument("--include-sensitive-hints", action="store_true", help="do not prefilter sensitive-context hints")
255+
parser.add_argument("--include-contacted-repos", action="store_true", help="include repos already contacted in logs")
256+
parser.add_argument("--no-llm", action="store_true", help="only print/search candidate JSON")
257+
parser.add_argument("--model", help="LLMGate model id")
258+
parser.add_argument("--max-tokens", type=int, default=1200)
259+
parser.add_argument("--timeout", type=int, default=45)
260+
parser.add_argument("--env", type=Path, default=None, help="LLMGate env path; defaults to local/llmgate.env")
261+
parser.add_argument("--sent-dir", type=Path, default=Path("leads/sent"))
262+
parser.add_argument("--output", type=Path, help="optional report path")
263+
return parser
264+
265+
266+
def main(argv: list[str] | None = None) -> int:
267+
args = build_parser().parse_args(argv)
268+
queries = args.queries or DEFAULT_QUERIES
269+
contacted_repos = set() if args.include_contacted_repos else sent_repos(args.sent_dir)
270+
candidates = search_candidates(queries, args.per_query, sent_urls(args.sent_dir), contacted_repos)
271+
if not args.include_sensitive_hints:
272+
candidates = [candidate for candidate in candidates if not has_sensitive_hint(candidate)]
273+
candidates = candidates[: args.max_candidates]
274+
text = render_no_llm(candidates) if args.no_llm else score_with_llmgate(args, candidates)
275+
write_or_print(text, args.output)
276+
return 0
277+
278+
279+
if __name__ == "__main__":
280+
sys.exit(main())

tests/test_repo_audit.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import repo_audit # noqa: E402
1414

1515
import llm_gateway # noqa: E402
16+
import scout_leads # noqa: E402
1617

1718

1819
class RepoAuditTests(unittest.TestCase):
@@ -111,6 +112,19 @@ def test_llm_gateway_env_parser(self) -> None:
111112
self.assertEqual(values["LLMGATE_BASE_URL"], "https://example.test/v1")
112113
self.assertEqual(values["LLMGATE_API_KEY"], "local-demo")
113114

115+
def test_scout_leads_normalizes_comment_urls(self) -> None:
116+
url = "https://github.com/example/project/issues/12#issuecomment-12345"
117+
118+
self.assertEqual(
119+
scout_leads.normalize_github_url(url),
120+
"https://github.com/example/project/issues/12",
121+
)
122+
123+
def test_scout_leads_extracts_repo_from_url(self) -> None:
124+
url = "https://github.com/example/project/pull/7#discussion_r1"
125+
126+
self.assertEqual(scout_leads.repo_from_github_url(url), "example/project")
127+
114128

115129
if __name__ == "__main__":
116130
unittest.main()

0 commit comments

Comments
 (0)