project-risk-agent 0.2.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.
@@ -0,0 +1,3 @@
1
+ """Project Risk Agent."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import defaultdict
5
+ from hashlib import sha256
6
+
7
+ from project_risk_agent.decisions import extract_decision_requests
8
+ from project_risk_agent.evidence import evidence_for_signals
9
+ from project_risk_agent.models import Evidence, Finding, FindingType, ProjectSignal, RiskCategory
10
+ from project_risk_agent.temporal import has_repeated_change, signal_sequence
11
+
12
+ PATTERNS: tuple[tuple[str, RiskCategory], ...] = (
13
+ (r"\b(delayed?|slipp(?:ed|ing)|miss(?:ed|ing)|behind|cannot start|can't start)\b", RiskCategory.SCHEDULE),
14
+ (r"\b(moved from .+ to .+|date change|date changed|schedule changed|second date change|another date change)\b", RiskCategory.SCHEDULE),
15
+ (r"\b(depend(?:ency|encies)|depends on|waiting for|blocked by|until .+ is available)\b", RiskCategory.DEPENDENCY),
16
+ (r"\b(understaffed|no capacity|resource constraint|vacancy|bandwidth)\b", RiskCategory.RESOURCE),
17
+ (r"\b(scope creep|out of scope|requirements changed|change request)\b", RiskCategory.SCOPE),
18
+ (r"\b(defect|bug|failed test|quality issue|regression)\b", RiskCategory.QUALITY),
19
+ (r"\b(vendor|supplier|third party)\b", RiskCategory.VENDOR),
20
+ (r"\b(budget|cost overrun|funding|financial)\b", RiskCategory.FINANCIAL),
21
+ (r"\b(customer escalation|stakeholder concern|sponsor concern)\b", RiskCategory.STAKEHOLDER),
22
+ (r"\b(outage|integration failure|performance issue|technical debt)\b", RiskCategory.TECHNICAL),
23
+ )
24
+
25
+
26
+ def _matched_categories(text: str) -> list[RiskCategory]:
27
+ lowered = text.lower()
28
+ return [category for pattern, category in PATTERNS if re.search(pattern, lowered)]
29
+
30
+
31
+ def _finding_id(signals: list[ProjectSignal], finding_type: FindingType, category: RiskCategory) -> str:
32
+ raw = ":".join(sorted(s.id for s in signals)) + f":{finding_type.value}:{category.value}"
33
+ return "finding-" + sha256(raw.encode()).hexdigest()[:12]
34
+
35
+
36
+ class SignalReasoner:
37
+ """Conservative, evidence-first reasoning baseline."""
38
+
39
+ def analyze(self, signals: list[ProjectSignal]) -> list[Finding]:
40
+ findings: list[Finding] = []
41
+ ordered = signal_sequence(signals)
42
+ decision_requests = {request.signal_id: request for request in extract_decision_requests(signals)}
43
+ for signal in signals:
44
+ text = signal.content.strip()
45
+ if not text:
46
+ continue
47
+ categories = _matched_categories(text)
48
+ finding_type = self._finding_type(text)
49
+ if not categories and finding_type != FindingType.DECISION:
50
+ continue
51
+ category = self._category(categories, finding_type)
52
+ decision = decision_requests.get(signal.id)
53
+ findings.append(
54
+ Finding(
55
+ id=_finding_id([signal], finding_type, category),
56
+ type=finding_type,
57
+ category=category,
58
+ title=self._title(finding_type, category),
59
+ description=text[:500],
60
+ likelihood=self._likelihood(text),
61
+ impact=self._impact(text),
62
+ urgency=self._urgency(text),
63
+ confidence=0.68,
64
+ evidence=[Evidence(signal_id=signal.id, excerpt=text[:500], rationale="Signal contains language associated with a management concern.")],
65
+ owner=decision.owner if decision else None,
66
+ decision_required=finding_type == FindingType.DECISION,
67
+ decision_owner=decision.owner if decision and finding_type == FindingType.DECISION else None,
68
+ decision_deadline=decision.deadline if decision and finding_type == FindingType.DECISION else None,
69
+ recommended_actions=self._actions(finding_type, category),
70
+ )
71
+ )
72
+ findings = self._corroborate(findings)
73
+ findings = self._infer_contradictions(ordered, findings)
74
+ findings = self._infer_cross_signal_schedule_risk(ordered, findings)
75
+ return self._infer_recurring_patterns(ordered, findings)
76
+
77
+ @staticmethod
78
+ def _category(categories: list[RiskCategory], finding_type: FindingType) -> RiskCategory:
79
+ if finding_type == FindingType.DEPENDENCY and RiskCategory.DEPENDENCY in categories:
80
+ return RiskCategory.DEPENDENCY
81
+ return categories[0] if categories else RiskCategory.OTHER
82
+
83
+ @staticmethod
84
+ def _corroborate(findings: list[Finding]) -> list[Finding]:
85
+ groups: dict[tuple[FindingType, RiskCategory], list[Finding]] = defaultdict(list)
86
+ for finding in findings:
87
+ groups[(finding.type, finding.category)].append(finding)
88
+ output: list[Finding] = []
89
+ for group in groups.values():
90
+ primary = group[0]
91
+ if len(group) > 1:
92
+ primary.evidence = [e for item in group for e in item.evidence]
93
+ primary.confidence = min(0.95, primary.confidence + 0.08 * (len(group) - 1))
94
+ primary.description = f"Corroborated by {len(group)} project signals. " + primary.description
95
+ output.append(primary)
96
+ return output
97
+
98
+ @staticmethod
99
+ def _infer_contradictions(signals: list[ProjectSignal], findings: list[Finding]) -> list[Finding]:
100
+ """Surface materially conflicting status language as a human-reviewable risk."""
101
+ positive = re.compile(r"\b(on track|on schedule|on time|still on track|no delay)\b", re.IGNORECASE)
102
+ negative = re.compile(r"\b(delayed?|slipp(?:ed|ing)|behind schedule|date changed|moved from .+ to .+)\b", re.IGNORECASE)
103
+ if not any(positive.search(s.content) for s in signals) or not any(negative.search(s.content) for s in signals):
104
+ return findings
105
+ evidence = evidence_for_signals(signals, lambda s: bool(positive.search(s.content) or negative.search(s.content)), "Conflicting schedule-status language requires human verification.")
106
+ existing = next((f for f in findings if f.type == FindingType.RISK and f.category == RiskCategory.SCHEDULE), None)
107
+ if existing:
108
+ existing.evidence = evidence
109
+ existing.confidence = min(0.95, max(existing.confidence, 0.84))
110
+ existing.description = "Project signals contain conflicting schedule-status statements."
111
+ existing.recommended_actions = ["Reconcile the conflicting status updates with the project owner and confirm the current schedule baseline."]
112
+ return findings
113
+ findings.append(Finding(id=_finding_id(signals, FindingType.RISK, RiskCategory.SCHEDULE), type=FindingType.RISK, category=RiskCategory.SCHEDULE, title="Conflicting schedule status", description="Project signals contain conflicting schedule-status statements.", likelihood="medium", impact="medium", urgency="medium", confidence=0.84, evidence=evidence, recommended_actions=["Reconcile the conflicting status updates with the project owner and confirm the current schedule baseline."]))
114
+ return findings
115
+
116
+ def _infer_cross_signal_schedule_risk(self, signals: list[ProjectSignal], findings: list[Finding]) -> list[Finding]:
117
+ movement_pattern = r"\b(moved from .+ to .+|date change|date changed|schedule changed|delayed?|slipp(?:ed|ing)|pushed)\b"
118
+ dependency_pattern = r"\b(depends on|dependency|dependencies|waiting for|blocked by|cannot start|until .+ is available)\b"
119
+ has_schedule_movement = any(re.search(movement_pattern, s.content.lower()) for s in signals)
120
+ has_downstream_dependency = any(re.search(dependency_pattern, s.content.lower()) for s in signals)
121
+ repeated_change = has_repeated_change(signals)
122
+ if not ((has_schedule_movement and has_downstream_dependency) or repeated_change):
123
+ return findings
124
+ evidence = evidence_for_signals(signals, lambda s: bool(re.search(movement_pattern + "|" + dependency_pattern, s.content.lower())), "Cross-signal evidence for schedule exposure.")
125
+ existing = next((f for f in findings if f.type == FindingType.RISK and f.category == RiskCategory.SCHEDULE), None)
126
+ if existing:
127
+ existing.evidence = evidence or existing.evidence
128
+ existing.confidence = min(0.95, max(existing.confidence, 0.82 if has_downstream_dependency else 0.78))
129
+ if repeated_change and has_downstream_dependency:
130
+ existing.description = "Repeated schedule movement is coupled to a downstream dependency."
131
+ existing.likelihood = "high"
132
+ elif repeated_change:
133
+ existing.description = "Repeated schedule or status changes indicate increasing schedule exposure."
134
+ return findings
135
+ findings.append(Finding(id=_finding_id(signals, FindingType.RISK, RiskCategory.SCHEDULE), type=FindingType.RISK, category=RiskCategory.SCHEDULE, title="Potential schedule concern", description="Repeated schedule or status changes are creating schedule exposure." if repeated_change and not has_downstream_dependency else "Schedule movement is coupled to a downstream dependency.", likelihood="high" if has_downstream_dependency else "medium", impact="medium", urgency="medium", confidence=0.82 if has_downstream_dependency else 0.78, evidence=evidence, recommended_actions=["Validate the dependency date and downstream contingency with the owners."]))
136
+ return findings
137
+
138
+ @staticmethod
139
+ def _infer_recurring_patterns(signals: list[ProjectSignal], findings: list[Finding]) -> list[Finding]:
140
+ """Increase attention when the same concern recurs across distinct project updates."""
141
+ category_counts: dict[RiskCategory, int] = defaultdict(int)
142
+ for signal in signals:
143
+ for category in _matched_categories(signal.content):
144
+ category_counts[category] += 1
145
+ for finding in findings:
146
+ count = category_counts.get(finding.category, 0)
147
+ if count < 3 or len(finding.evidence) < 2:
148
+ continue
149
+ finding.confidence = min(0.95, finding.confidence + 0.06)
150
+ if finding.likelihood == "medium":
151
+ finding.likelihood = "high"
152
+ finding.description = f"Recurring across {count} project signals. {finding.description}"
153
+ finding.recommended_actions.append("Review the recurring pattern and address the underlying cause rather than the latest symptom.")
154
+ return findings
155
+
156
+ @staticmethod
157
+ def _finding_type(text: str) -> FindingType:
158
+ lowered = text.lower()
159
+ if re.search(r"\b(decision|decide|approval|approve|choose|needs sign[- ]off)\b", lowered):
160
+ return FindingType.DECISION
161
+ if re.search(r"\b(risk|at risk|may miss|might miss|could miss|potential)\b", lowered):
162
+ return FindingType.RISK
163
+ if re.search(r"\b(already missed|has failed|failed test|outage|currently blocked|currently unavailable)\b", lowered):
164
+ return FindingType.ISSUE
165
+ if re.search(r"\b(depends on|dependency|dependencies|waiting for|blocked by|cannot start|can't start|until .+ is available)\b", lowered):
166
+ return FindingType.DEPENDENCY
167
+ return FindingType.RISK
168
+
169
+ @staticmethod
170
+ def _title(finding_type: FindingType, category: RiskCategory) -> str:
171
+ prefix = {FindingType.RISK: "Potential", FindingType.ISSUE: "Active", FindingType.DEPENDENCY: "Dependency", FindingType.DECISION: "Decision needed for"}[finding_type]
172
+ return f"{prefix} {category.value} concern"
173
+
174
+ @staticmethod
175
+ def _likelihood(text: str) -> str:
176
+ lowered = text.lower()
177
+ return "high" if re.search(r"\b(blocked|cannot|critical|miss|failed)\b", lowered) else "medium"
178
+
179
+ @staticmethod
180
+ def _impact(text: str) -> str:
181
+ lowered = text.lower()
182
+ return "high" if re.search(r"\b(launch|deadline|critical|customer|revenue|production)\b", lowered) else "medium"
183
+
184
+ @staticmethod
185
+ def _urgency(text: str) -> str:
186
+ lowered = text.lower()
187
+ return "high" if re.search(r"\b(today|tomorrow|blocked|critical|production|outage)\b", lowered) else "medium"
188
+
189
+ @staticmethod
190
+ def _actions(finding_type: FindingType, category: RiskCategory) -> list[str]:
191
+ if finding_type == FindingType.DECISION:
192
+ return ["Confirm the decision owner and required decision date."]
193
+ if finding_type == FindingType.DEPENDENCY:
194
+ return ["Confirm the dependency owner, required-by date, and contingency."]
195
+ if finding_type == FindingType.ISSUE:
196
+ return ["Assign an owner, recovery action, and next checkpoint."]
197
+ return [f"Validate the {category.value} concern with the responsible owner."]
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from project_risk_agent.analysis import SignalReasoner
4
+ from project_risk_agent.models import Finding, ProjectSignal
5
+
6
+
7
+ class RiskAnalyzer:
8
+ """Public analysis facade for the project risk agent."""
9
+
10
+ def __init__(self, reasoner: SignalReasoner | None = None) -> None:
11
+ self.reasoner = reasoner or SignalReasoner()
12
+
13
+ def analyze(self, signals: list[ProjectSignal]) -> list[Finding]:
14
+ return self.reasoner.analyze(signals)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ from pydantic import BaseModel
5
+
6
+ from project_risk_agent.analyzer import RiskAnalyzer
7
+ from project_risk_agent.brief import management_brief
8
+ from project_risk_agent.models import ProjectSignal
9
+ from project_risk_agent.providers import DeterministicProvider, ModelProvider
10
+ from project_risk_agent.service import AnalysisResult, RiskAnalysisService
11
+
12
+
13
+ class AnalyzeRequest(BaseModel):
14
+ signals: list[ProjectSignal]
15
+
16
+
17
+ def _changes(result: AnalysisResult) -> list[dict[str, object]]:
18
+ return [asdict(change) | {"attention_direction": change.attention_direction} for change in result.changed_findings]
19
+
20
+
21
+ def _trajectories(result: AnalysisResult) -> list[dict[str, object]]:
22
+ return [asdict(item) for item in result.trajectories]
23
+
24
+
25
+ def _freshness(result: AnalysisResult) -> list[dict[str, object]]:
26
+ return [asdict(item) for item in result.freshness or []]
27
+
28
+
29
+ def _decisions(result: AnalysisResult) -> list[dict[str, object]]:
30
+ return [asdict(item) for item in result.decision_requests or []]
31
+
32
+
33
+ def _dependencies(result: AnalysisResult) -> list[dict[str, object]]:
34
+ return [asdict(item) for item in result.dependencies or []]
35
+
36
+
37
+ def create_app(provider: ModelProvider | None = None):
38
+ try:
39
+ from fastapi import FastAPI
40
+ from fastapi.middleware.cors import CORSMiddleware
41
+ except ImportError as exc: # pragma: no cover - exercised only without API extra
42
+ raise RuntimeError("Install the 'api' extra to run the HTTP API") from exc
43
+
44
+ app = FastAPI(title="Project Risk Agent", version="0.1.0")
45
+ app.add_middleware(
46
+ CORSMiddleware,
47
+ allow_origins=["http://127.0.0.1:8001", "http://localhost:8001"],
48
+ allow_methods=["GET", "POST"],
49
+ allow_headers=["Content-Type"],
50
+ )
51
+ service = RiskAnalysisService(provider or DeterministicProvider(RiskAnalyzer()))
52
+
53
+ @app.get("/health")
54
+ def health() -> dict[str, str]:
55
+ return {"status": "ok"}
56
+
57
+ def response(result: AnalysisResult) -> dict[str, object]:
58
+ return {
59
+ "analyzed_at": result.analyzed_at,
60
+ "signals_analyzed": result.signals_analyzed,
61
+ "trend": result.trend,
62
+ "findings": result.management_attention,
63
+ "changed_findings": _changes(result),
64
+ "trajectories": _trajectories(result),
65
+ "freshness": _freshness(result),
66
+ "decision_requests": _decisions(result),
67
+ "dependencies": _dependencies(result),
68
+ }
69
+
70
+ @app.post("/analyze")
71
+ def analyze(request: AnalyzeRequest) -> dict[str, object]:
72
+ return response(service.analyze(request.signals))
73
+
74
+ @app.post("/brief")
75
+ def brief(request: AnalyzeRequest) -> dict[str, object]:
76
+ result = service.analyze(request.signals)
77
+ return response(result) | {
78
+ "markdown": management_brief(
79
+ result.management_attention, result.decision_requests, result.dependencies
80
+ )
81
+ }
82
+
83
+ return app
84
+
85
+
86
+ app = create_app()
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from project_risk_agent.decisions import DecisionRequest
4
+ from project_risk_agent.dependencies import DependencyItem
5
+ from project_risk_agent.models import Finding
6
+
7
+
8
+ def management_brief(
9
+ findings: list[Finding],
10
+ decision_requests: list[DecisionRequest] | None = None,
11
+ dependencies: list[DependencyItem] | None = None,
12
+ ) -> str:
13
+ """Render findings as a concise, human-reviewable management brief."""
14
+ if not findings and not decision_requests and not dependencies:
15
+ return "No management-relevant findings detected."
16
+
17
+ lines = ["# Project Risk Brief", ""]
18
+ for index, finding in enumerate(findings, start=1):
19
+ lines.extend([
20
+ f"## {index}. {finding.title}",
21
+ f"**Type:** {finding.type.value} **Category:** {finding.category.value}",
22
+ f"**Confidence:** {finding.confidence:.0%}",
23
+ "",
24
+ finding.description,
25
+ "",
26
+ "### Evidence",
27
+ ])
28
+ for evidence in finding.evidence:
29
+ lines.append(f"- `{evidence.signal_id}` — {evidence.excerpt}")
30
+ if finding.recommended_actions:
31
+ lines.extend(["", "### Suggested next actions"])
32
+ lines.extend(f"- {action}" for action in finding.recommended_actions)
33
+ if finding.decision_required:
34
+ lines.extend(["", "**Management decision required:** Yes"])
35
+ lines.append("")
36
+ if decision_requests:
37
+ lines.extend(["## Decision queue", ""])
38
+ for request in decision_requests:
39
+ details = [f"**Readiness:** {request.readiness.replace('_', ' ')}"]
40
+ if request.owner:
41
+ details.append(f"**Owner:** {request.owner}")
42
+ if request.deadline:
43
+ details.append(f"**Deadline:** {request.deadline}")
44
+ lines.extend([f"- `{request.signal_id}` — {request.description}", " " + " ".join(details)])
45
+ lines.append("")
46
+ if dependencies:
47
+ lines.extend(["## Dependency queue", ""])
48
+ for dependency in dependencies:
49
+ details = [f"**Status:** {dependency.status}"]
50
+ if dependency.owner:
51
+ details.append(f"**Owner:** {dependency.owner}")
52
+ if dependency.required_by:
53
+ details.append(f"**Required by:** {dependency.required_by}")
54
+ lines.extend([f"- `{dependency.finding_id}` — {dependency.title}", " " + " ".join(details)])
55
+ lines.append("")
56
+ return "\n".join(lines)
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from project_risk_agent.analyzer import RiskAnalyzer
8
+ from project_risk_agent.brief import management_brief
9
+ from project_risk_agent.connectors.file import FileConnector
10
+ from project_risk_agent.prioritizer import prioritize
11
+ from project_risk_agent.providers import DeterministicProvider, openai_provider_from_environment
12
+ from project_risk_agent.service import RiskAnalysisService
13
+ from project_risk_agent.state import JsonProjectStateStore
14
+
15
+
16
+ def main() -> None:
17
+ parser = argparse.ArgumentParser(description="Analyze project information for management risks.")
18
+ parser.add_argument("path", type=Path, help="Text or JSON file containing project signals")
19
+ parser.add_argument("--brief", action="store_true", help="Print a concise management brief")
20
+ parser.add_argument("--all", action="store_true", help="Include all findings instead of prioritized findings")
21
+ parser.add_argument(
22
+ "--provider",
23
+ choices=("deterministic", "openai"),
24
+ default="deterministic",
25
+ help="Reasoning provider (default: deterministic)",
26
+ )
27
+ parser.add_argument(
28
+ "--model",
29
+ help="OpenAI model ID; required with --provider openai unless configured in the environment",
30
+ )
31
+ parser.add_argument(
32
+ "--state",
33
+ type=Path,
34
+ help="Persist project intelligence at this path and report changes from the prior run",
35
+ )
36
+ args = parser.parse_args()
37
+
38
+ signals = FileConnector().load(args.path)
39
+ provider = (
40
+ openai_provider_from_environment(args.model)
41
+ if args.provider == "openai"
42
+ else DeterministicProvider(RiskAnalyzer())
43
+ )
44
+ service = RiskAnalysisService(provider)
45
+ result = service.analyze_with_state(signals, JsonProjectStateStore(args.state)) if args.state else service.analyze(signals)
46
+ findings = result.findings if args.all else prioritize(result.findings)
47
+
48
+ if args.brief:
49
+ print(management_brief(findings, result.decision_requests, result.dependencies))
50
+ print(f"\nTrajectory: {result.trend}")
51
+ for trajectory in result.trajectories:
52
+ print(f"- {trajectory.finding_id}: {trajectory.state} ({trajectory.freshness})")
53
+ if result.changed_findings:
54
+ print("\nChanged findings:")
55
+ for change in result.changed_findings:
56
+ fields = ", ".join(change.changed_fields)
57
+ print(f"- {change.finding_id}: {change.attention_direction} ({fields})")
58
+ return
59
+
60
+ payload = {
61
+ "analyzed_at": result.analyzed_at,
62
+ "signals_analyzed": result.signals_analyzed,
63
+ "trend": result.trend,
64
+ "findings": [finding.model_dump(mode="json") for finding in findings],
65
+ "trajectories": [
66
+ {
67
+ "finding_id": item.finding_id,
68
+ "state": item.state,
69
+ "attention_direction": item.attention_direction,
70
+ "freshness": item.freshness,
71
+ }
72
+ for item in result.trajectories
73
+ ],
74
+ "freshness": [
75
+ {
76
+ "finding_id": item.finding_id,
77
+ "age_days": item.age_days,
78
+ "status": item.status,
79
+ }
80
+ for item in result.freshness or []
81
+ ],
82
+ "decision_requests": [
83
+ {
84
+ "signal_id": item.signal_id,
85
+ "description": item.description,
86
+ "owner": item.owner,
87
+ "deadline": item.deadline,
88
+ "readiness": item.readiness,
89
+ }
90
+ for item in result.decision_requests or []
91
+ ],
92
+ "dependencies": [
93
+ {
94
+ "finding_id": item.finding_id,
95
+ "title": item.title,
96
+ "status": item.status,
97
+ "evidence_signal_ids": item.evidence_signal_ids,
98
+ "owner": item.owner,
99
+ "required_by": item.required_by,
100
+ }
101
+ for item in result.dependencies or []
102
+ ],
103
+ "changed_findings": [
104
+ {
105
+ "finding_id": change.finding_id,
106
+ "changed_fields": change.changed_fields,
107
+ "previous_attention": change.previous_attention,
108
+ "current_attention": change.current_attention,
109
+ "attention_direction": change.attention_direction,
110
+ }
111
+ for change in result.changed_findings
112
+ ],
113
+ }
114
+ print(json.dumps(payload, indent=2, default=str))
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
@@ -0,0 +1,8 @@
1
+ """Project data connectors."""
2
+
3
+ from project_risk_agent.connectors.base import ProjectConnector
4
+ from project_risk_agent.connectors.file import FileConnector
5
+ from project_risk_agent.connectors.jira import JiraConnector
6
+ from project_risk_agent.connectors.slack import SlackConnector
7
+
8
+ __all__ = ["FileConnector", "JiraConnector", "ProjectConnector", "SlackConnector"]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ from project_risk_agent.models import ProjectSignal
7
+
8
+
9
+ class ProjectConnector(ABC):
10
+ """Interface implemented by every external project-data connector."""
11
+
12
+ name: str
13
+
14
+ @abstractmethod
15
+ def authenticate(self) -> None:
16
+ """Validate or establish authentication for the connector."""
17
+ raise NotImplementedError
18
+
19
+ @abstractmethod
20
+ def get_projects(self) -> list[dict[str, Any]]:
21
+ """Return projects available to the authenticated user."""
22
+ raise NotImplementedError
23
+
24
+ @abstractmethod
25
+ def get_signals(self, project_id: str) -> list[ProjectSignal]:
26
+ """Return normalized signals for a project."""
27
+ raise NotImplementedError
28
+
29
+ @abstractmethod
30
+ def normalize(self, source_data: Any) -> ProjectSignal:
31
+ """Convert source-specific data into the common signal model."""
32
+ raise NotImplementedError
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from project_risk_agent.connectors.base import ProjectConnector
9
+ from project_risk_agent.models import ProjectSignal
10
+
11
+
12
+ class FileConnector(ProjectConnector):
13
+ """Ingest local text or JSON files for development and evaluation."""
14
+
15
+ name = "file"
16
+
17
+ def authenticate(self) -> None:
18
+ return None
19
+
20
+ def get_projects(self) -> list[dict[str, Any]]:
21
+ return [{"id": "local", "name": "Local project"}]
22
+
23
+ def get_signals(self, project_id: str) -> list[ProjectSignal]:
24
+ return []
25
+
26
+ def normalize(self, source_data: Any) -> ProjectSignal:
27
+ if isinstance(source_data, str):
28
+ content = source_data
29
+ metadata: dict[str, object] = {}
30
+ else:
31
+ content = str(source_data.get("content", ""))
32
+ metadata = dict(source_data.get("metadata", {}))
33
+
34
+ return ProjectSignal(
35
+ id=f"file-{abs(hash(content))}",
36
+ source="file",
37
+ source_type="document",
38
+ timestamp=datetime.now(UTC),
39
+ project_id=metadata.pop("project_id", None),
40
+ author=metadata.pop("author", None),
41
+ content=content,
42
+ metadata=metadata,
43
+ provenance={"connector": self.name},
44
+ )
45
+
46
+ def load(self, path: str | Path) -> list[ProjectSignal]:
47
+ file_path = Path(path)
48
+ if file_path.suffix.lower() == ".json":
49
+ data = json.loads(file_path.read_text(encoding="utf-8"))
50
+ items = data if isinstance(data, list) else [data]
51
+ else:
52
+ items = [file_path.read_text(encoding="utf-8")]
53
+ return [self.normalize(item) for item in items]