Coverage for little_loops / learning_tests / extractor.py: 0%
38 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""LLM-based extraction of external API dependencies from issue text (ENH-2209).
3Exposes ``extract_learning_targets()`` as an importable callable Python module
4so downstream tools (ENH-2210 sprint pre-flight) can import it directly without
5a shell-out. The Anthropic SDK is used for SDK-direct invocation; the ``llm_call``
6parameter allows mock injection for unit tests.
8Follow the same pattern as ``gate.py`` (``is_record_stale``) — importable helper,
9unit-testable with mock injection.
10"""
12from __future__ import annotations
14import json
15import re
16from collections.abc import Callable
18from little_loops.issue_parser import slugify
20_EXTRACTION_PROMPT = """\
21Analyze the following issue text and identify all external packages, SDKs, or \
22third-party API surfaces that the implementation plan assumes behavior of.
24Include:
25- Third-party Python packages (e.g. anthropic, requests, boto3, stripe)
26- External APIs and services (e.g. Stripe webhooks, GitHub API)
27- SDKs for external platforms or cloud services
28- Non-obvious stdlib components whose contract is non-trivial (e.g. asyncio, multiprocessing)
30Exclude:
31- Code internal to the project being built
32- Standard Python builtins (str, dict, list, int, etc.)
33- Contract-stable stdlib modules (os, sys, pathlib, json, re, datetime)
35For each identified dependency, provide its canonical short name only \
36(no version qualifier, no description).
38Return a JSON object as the LAST line of your response in exactly this format:
39TARGETS_JSON:{{"targets": ["name1", "name2"], "count": N}}
41If there are no external dependencies, return:
42TARGETS_JSON:{{"targets": [], "count": 0}}
44Issue text to analyze:
45---
46{issue_text}
47---\
48"""
50_TARGETS_JSON_RE = re.compile(r"TARGETS_JSON:(\{.*\})", re.MULTILINE)
53def _default_llm_call(prompt: str) -> str:
54 """Call the Anthropic API via the SDK and return the response text."""
55 import anthropic
57 client = anthropic.Anthropic()
58 message = client.messages.create(
59 model="claude-haiku-4-5-20251001",
60 max_tokens=1024,
61 messages=[{"role": "user", "content": prompt}],
62 )
63 block = message.content[0]
64 if not isinstance(block, anthropic.types.TextBlock):
65 return ""
66 return block.text
69def extract_learning_targets(
70 issue_text: str,
71 *,
72 llm_call: Callable[[str], str] | None = None,
73) -> list[str]:
74 """Extract external API dependency names from issue text via LLM.
76 Returns a deduplicated list of target names. Issues with no external
77 dependencies return an empty list; callers should omit the frontmatter
78 field rather than writing an empty list.
80 Args:
81 issue_text: Full issue file content (frontmatter + body).
82 llm_call: Optional callable accepting a prompt string and returning
83 response text. Defaults to SDK-direct Anthropic call.
84 Inject a mock for unit tests.
86 Returns:
87 Deduplicated list of target names (first-seen form preserved),
88 e.g. ``["anthropic", "requests"]``.
89 """
90 caller = llm_call if llm_call is not None else _default_llm_call
91 prompt = _EXTRACTION_PROMPT.format(issue_text=issue_text)
92 response = caller(prompt)
94 match = _TARGETS_JSON_RE.search(response)
95 if not match:
96 return []
98 try:
99 data = json.loads(match.group(1))
100 except (json.JSONDecodeError, KeyError):
101 return []
103 raw_targets: list[str] = data.get("targets") or []
104 seen: set[str] = set()
105 result: list[str] = []
106 for t in raw_targets:
107 name = t.strip()
108 if not name:
109 continue
110 slug = slugify(name)
111 if slug not in seen:
112 seen.add(slug)
113 result.append(name)
114 return result