Skip to content

Commit f688142

Browse files
committed
feat(api): implement Python FastAPI service for behavioral risk scoring with Docker support
1 parent 59f97ca commit f688142

8 files changed

Lines changed: 497 additions & 10 deletions

File tree

python-service/Dockerfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
FROM python:3.12-slim
2+
3+
WORKDIR /app
4+
5+
COPY requirements.txt ./
6+
RUN python -m pip install --no-cache-dir -r requirements.txt
7+
8+
COPY . /app
9+
10+
EXPOSE 8000
11+
12+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

python-service/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# SentinelOS Python Risk Service Prototype
2+
3+
This folder contains a minimal FastAPI prototype for a Python-based behavioral risk scoring service.
4+
5+
## What it provides
6+
7+
- `app.py`: FastAPI application exposing `/predict-risk` and `/health`
8+
- `schemas.py`: Pydantic models for behavioral events and risk requests
9+
- `ml_model.py`: Prototype ML model using `pandas` and `scikit-learn`
10+
- `requirements.txt`: Python dependency list
11+
- `Dockerfile`: Container image definition for the service
12+
13+
## Run locally
14+
15+
```powershell
16+
cd python-service
17+
python -m venv .venv
18+
.\.venv\Scripts\activate
19+
python -m pip install --upgrade pip
20+
python -m pip install -r requirements.txt
21+
uvicorn app:app --reload --host 0.0.0.0 --port 8000
22+
```
23+
24+
## Example request
25+
26+
```powershell
27+
curl -X POST "http://127.0.0.1:8000/predict-risk" -H "Content-Type: application/json" -d "{
28+
\"user_id\": \"user-123\",
29+
\"session_id\": \"session-abc\",
30+
\"events\": [
31+
{
32+
\"event_type\": \"login_failure\",
33+
\"timestamp\": \"2026-06-24T14:00:00Z\",
34+
\"metadata\": { \"duration\": 30, \"high_risk_action\": true, \"confidence\": 0.8 }
35+
},
36+
{
37+
\"event_type\": \"privilege_change\",
38+
\"timestamp\": \"2026-06-24T14:05:00Z\",
39+
\"metadata\": { \"duration\": 120, \"high_risk_action\": true, \"confidence\": 0.95 }
40+
}
41+
]
42+
}"
43+
```
44+
45+
## Notes
46+
47+
This is a prototype to demonstrate how a Python FastAPI service can handle event payloads, validate them with Pydantic, and apply a simple ML-style risk model.
48+
49+
For production, you can expand the service by:
50+
51+
- loading a serialized model (`joblib` / `pickle` / `onnx`)
52+
- adding authentication and request validation middleware
53+
- using a real training dataset and feature engineering pipeline
54+
- versioning the API and ML model separately

python-service/app.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from fastapi import FastAPI
2+
from fastapi.responses import JSONResponse
3+
4+
from ml_model import RiskModel
5+
from schemas import RiskRequest, RiskResponse
6+
7+
app = FastAPI(
8+
title="SentinelOS Python Risk Service",
9+
description="Prototype FastAPI service for behavioral risk scoring",
10+
version="0.1.0",
11+
)
12+
13+
model = RiskModel()
14+
15+
@app.post("/predict-risk", response_model=RiskResponse)
16+
async def predict_risk(request: RiskRequest):
17+
df = request.to_dataframe()
18+
prediction = model.predict(df)
19+
return RiskResponse(**prediction)
20+
21+
@app.get("/health")
22+
async def health():
23+
return JSONResponse({"status": "ok", "model_version": model.version})

python-service/ml_model.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Dict, List
4+
5+
import numpy as np
6+
import pandas as pd
7+
from sklearn.linear_model import LogisticRegression
8+
from sklearn.pipeline import Pipeline
9+
from sklearn.preprocessing import StandardScaler
10+
11+
FEATURE_COLUMNS = [
12+
"event_count",
13+
"unique_event_types",
14+
"total_duration",
15+
"avg_confidence",
16+
"high_risk_actions",
17+
]
18+
19+
20+
class RiskModel:
21+
version = "0.1.0"
22+
23+
def __init__(self) -> None:
24+
self.pipeline = Pipeline(
25+
[
26+
("scaler", StandardScaler()),
27+
("clf", LogisticRegression(random_state=42, max_iter=500)),
28+
]
29+
)
30+
X_train, y_train = self._generate_training_data()
31+
self.pipeline.fit(X_train, y_train)
32+
33+
def predict(self, df: pd.DataFrame) -> Dict[str, Any]:
34+
vector = self._extract_feature_vector(df)
35+
score = float(self.pipeline.predict_proba([vector])[0, 1])
36+
return {
37+
"user_id": df.attrs.get("user_id", "unknown"),
38+
"session_id": df.attrs.get("session_id"),
39+
"risk_score": score,
40+
"risk_level": self._to_risk_level(score),
41+
"model_version": self.version,
42+
"explanation": self._explain(vector, score),
43+
}
44+
45+
def _generate_training_data(self) -> tuple[np.ndarray, np.ndarray]:
46+
rng = np.random.default_rng(42)
47+
rows: List[List[float]] = []
48+
labels: List[int] = []
49+
50+
for _ in range(500):
51+
event_count = float(rng.integers(1, 24))
52+
unique_event_types = float(rng.integers(1, min(5, int(event_count))))
53+
total_duration = float(rng.integers(0, 1200))
54+
avg_confidence = float(rng.uniform(0.2, 0.98))
55+
high_risk_actions = float(rng.integers(0, 5))
56+
57+
risk = 1 if (event_count > 10 and high_risk_actions >= 2) or total_duration > 600 else 0
58+
rows.append([event_count, unique_event_types, total_duration, avg_confidence, high_risk_actions])
59+
labels.append(risk)
60+
61+
return np.array(rows), np.array(labels)
62+
63+
def _extract_feature_vector(self, df: pd.DataFrame) -> List[float]:
64+
event_count = float(len(df))
65+
unique_event_types = float(df["event_type"].nunique()) if not df.empty else 0.0
66+
67+
total_duration = 0.0
68+
confidences: List[float] = []
69+
high_risk_actions = 0.0
70+
71+
for metadata in df["metadata"]:
72+
if isinstance(metadata, dict):
73+
total_duration += float(metadata.get("duration", 0.0) or 0.0)
74+
confidence = metadata.get("confidence")
75+
if isinstance(confidence, (int, float)):
76+
confidences.append(float(confidence))
77+
if metadata.get("high_risk_action"):
78+
high_risk_actions += 1.0
79+
80+
avg_confidence = float(np.mean(confidences)) if confidences else 0.0
81+
return [event_count, unique_event_types, total_duration, avg_confidence, high_risk_actions]
82+
83+
def _to_risk_level(self, score: float) -> str:
84+
if score >= 0.65:
85+
return "high"
86+
if score >= 0.35:
87+
return "medium"
88+
return "low"
89+
90+
def _explain(self, vector: List[float], score: float) -> Dict[str, Any]:
91+
return {
92+
"features": dict(zip(FEATURE_COLUMNS, vector)),
93+
"thresholds": {"low": 0.35, "high": 0.65},
94+
"prediction_probability": score,
95+
}

python-service/requirements.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
fastapi==0.109.0
2+
uvicorn[standard]==0.23.2
3+
pydantic==2.9.0
4+
pandas==2.2.2
5+
scikit-learn==1.4.0
6+
numpy==1.27.0
7+
python-dateutil==2.8.2

python-service/schemas.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
from datetime import datetime
3+
from typing import Any, Dict, List, Optional
4+
5+
import pandas as pd
6+
from pydantic import BaseModel, Field
7+
8+
9+
class BehavioralEvent(BaseModel):
10+
event_type: str = Field(..., description="Type of behavioral event")
11+
timestamp: datetime
12+
metadata: Dict[str, Any] = Field(default_factory=dict)
13+
14+
15+
class RiskRequest(BaseModel):
16+
user_id: str
17+
session_id: Optional[str] = None
18+
events: List[BehavioralEvent]
19+
context: Dict[str, Any] = Field(default_factory=dict)
20+
21+
def to_dataframe(self) -> pd.DataFrame:
22+
rows = []
23+
for event in self.events:
24+
rows.append(
25+
{
26+
"event_type": event.event_type,
27+
"timestamp": event.timestamp.timestamp(),
28+
"metadata": event.metadata,
29+
}
30+
)
31+
32+
df = pd.DataFrame(rows)
33+
df.attrs["user_id"] = self.user_id
34+
df.attrs["session_id"] = self.session_id
35+
return df
36+
37+
38+
class RiskResponse(BaseModel):
39+
user_id: str
40+
session_id: Optional[str]
41+
risk_score: float
42+
risk_level: str
43+
model_version: str
44+
explanation: Dict[str, Any]

server/api/controllers/insightsController.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { analyzeEnergy } from '../../../intelligence/energyAnalyzer';
44
import { generateEnergyInsights } from '../../../intelligence/insightGenerator';
55
import { getLogs } from '../../services/insightsService';
66
import { generateInsightsFromLogs } from '../../../internal/intelligence/insightGenerator';
7+
import {
8+
buildInsightsFromAnalytics,
9+
getBehaviorAnalytics,
10+
} from '../../services/analyticsService';
711

812
// Simple in-memory cache for analysis/insights (per-process). Cached for 24h by default.
913
let cached: { ts: number; insights: any[]; analysis: any } | null = null;
@@ -17,7 +21,6 @@ export async function getInsights(_req: Request, res: Response) {
1721
const force = _req.query && String(_req.query.force) === 'true';
1822
if (!force) {
1923
const persisted = await prisma.insight.findFirst({
20-
where: { behaviorType: 'ENERGY' },
2124
orderBy: { generatedAt: 'desc' },
2225
});
2326
if (persisted) {
@@ -40,9 +43,8 @@ export async function getInsights(_req: Request, res: Response) {
4043
// persist empty result briefly to avoid repeated cheap requests
4144
await prisma.insight.create({
4245
data: {
43-
behaviorType: 'ENERGY',
44-
insights: [],
45-
analysis: { totalLogs: logs ? logs.length : 0 },
46+
insights: [] as any,
47+
analysis: { totalLogs: logs ? logs.length : 0 } as any,
4648
},
4749
});
4850
cached = { ts: Date.now(), insights: [], analysis: { totalLogs: logs ? logs.length : 0 } };
@@ -56,9 +58,8 @@ export async function getInsights(_req: Request, res: Response) {
5658
// Persist generated insights (cache)
5759
await prisma.insight.create({
5860
data: {
59-
behaviorType: 'ENERGY',
60-
insights,
61-
analysis,
61+
insights: insights as any,
62+
analysis: analysis as any,
6263
},
6364
});
6465

@@ -83,7 +84,7 @@ export async function listPersistedInsights(_req: Request, res: Response) {
8384

8485
export async function deleteInsight(req: Request, res: Response) {
8586
try {
86-
const { id } = req.params;
87+
const id = String(req.params.id);
8788
await prisma.insight.delete({ where: { id } });
8889
res.json({ ok: true });
8990
} catch (err) {
@@ -108,8 +109,24 @@ export async function refreshInsights(_req: Request, res: Response) {
108109

109110
export async function getInsightsSimple(_req: Request, res: Response) {
110111
try {
111-
const logs = await getLogs();
112-
const report = generateInsightsFromLogs(logs);
112+
const sinceAnalytics = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
113+
const analytics = await getBehaviorAnalytics({ behaviorType: 'ENERGY', since: sinceAnalytics });
114+
const insights = buildInsightsFromAnalytics(analytics);
115+
116+
const recentSince = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
117+
const recentLogs = await prisma.log.findMany({
118+
where: { behaviorType: 'ENERGY', timestamp: { gte: recentSince } },
119+
orderBy: { timestamp: 'asc' },
120+
});
121+
122+
const report = {
123+
analysis: analytics,
124+
insights,
125+
explainableGuidance: insights.map((item) => ({ message: item.message })),
126+
weeklyRecommendations: [],
127+
logs: recentLogs,
128+
};
129+
113130
res.json(report);
114131
} catch (err) {
115132
console.error(err);

0 commit comments

Comments
 (0)