|
# retoor <retoor@molodetz.nl>
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import AsyncIterator
|
|
|
|
from devplacepy.services.jobs.isslop.acquisition.domcapture import DomSnapshot
|
|
from devplacepy.services.jobs.isslop.acquisition.git import CloneFailedError, RepositoryTooLargeError, clone_repository
|
|
from devplacepy.services.jobs.isslop.acquisition.source import KIND_GIT, is_private_host, resolve_source
|
|
from devplacepy.services.jobs.isslop.acquisition.website import crawl_website
|
|
from devplacepy.services.jobs.isslop.acquisition.workspace import content_hash, remove_workspace, reset_workspace
|
|
from devplacepy.services.jobs.isslop.agent.classifier import AiVerdict, classify_file, select_samples
|
|
from devplacepy.services.jobs.isslop.agent.llm import LlmClient
|
|
from devplacepy.services.jobs.isslop.agent.reporter import generate_report
|
|
from devplacepy.services.jobs.isslop.agent.vision import (
|
|
ImageVerdict,
|
|
classify_image,
|
|
collect_images,
|
|
image_summary,
|
|
make_thumbnail,
|
|
persist_screenshot,
|
|
)
|
|
from devplacepy.services.jobs.isslop.analysis.domsignals.aggregate import aggregate_dom_evidence
|
|
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext
|
|
from devplacepy.services.jobs.isslop.analysis.domsignals.builders import detected_builder as detected_builder_for
|
|
from devplacepy.services.jobs.isslop.analysis.domsignals.registry import run_dom_checks
|
|
from devplacepy.services.jobs.isslop.analysis.engine import build_inventory, compute_repo_baselines, load_context, score_file
|
|
from devplacepy.services.jobs.isslop.analysis.scoring import (
|
|
FileScore,
|
|
adjust_for_dom_signals,
|
|
adjust_for_images,
|
|
adjust_for_template,
|
|
aggregate,
|
|
categorize,
|
|
criticality_for,
|
|
repo_scores_to_dict,
|
|
)
|
|
from devplacepy.services.jobs.isslop.analysis.signals import SEVERITY_STRONG
|
|
from devplacepy.services.jobs.isslop.analysis.templates import TemplateEvidence, detect_template
|
|
from devplacepy.services.jobs.isslop.config import AI_EXCERPT_CHARS, IMAGE_CONCURRENCY, WorkerSettings
|
|
from devplacepy.services.jobs.isslop.events import (
|
|
KIND_AI,
|
|
KIND_DOM,
|
|
KIND_DONE,
|
|
KIND_ERROR,
|
|
KIND_FILE,
|
|
KIND_LOG,
|
|
KIND_PROGRESS,
|
|
KIND_REPORT,
|
|
KIND_SCORE,
|
|
KIND_SIGNAL,
|
|
KIND_STAGE,
|
|
STAGE_ACQUIRE,
|
|
STAGE_AI,
|
|
STAGE_DOM,
|
|
STAGE_IMAGES,
|
|
STAGE_INVENTORY,
|
|
STAGE_REPORT,
|
|
STAGE_RESOLVE,
|
|
STAGE_SCORE,
|
|
STAGE_STATIC,
|
|
KIND_IMAGE,
|
|
WorkerEvent,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROGRESS_EVERY_FILES: int = 5
|
|
SOURCE_CAP_FILES: int = 60
|
|
SOURCE_CAP_BYTES: int = 200000
|
|
STATIC_BLEND_WEIGHT: float = 0.6
|
|
AI_BLEND_WEIGHT: float = 0.4
|
|
AI_REVIEW_CONCURRENCY: int = 4
|
|
|
|
|
|
def _persist_source(media_dir: Path | None, relative: str, text: str) -> str | None:
|
|
if media_dir is None:
|
|
return None
|
|
name = f"s{hashlib.sha1(relative.encode('utf-8')).hexdigest()[:16]}.txt"
|
|
try:
|
|
media_dir.mkdir(parents=True, exist_ok=True)
|
|
(media_dir / name).write_text(text[:SOURCE_CAP_BYTES], encoding="utf-8")
|
|
except OSError as error:
|
|
logger.warning("Source snippet persist failed for %s: %s", relative, error)
|
|
return None
|
|
return name
|
|
|
|
|
|
def _stage(stage: str, message: str) -> WorkerEvent:
|
|
return WorkerEvent(kind=KIND_STAGE, message=message, data={"stage": stage})
|
|
|
|
|
|
def _blend(static_value: float, ai_value: float) -> float:
|
|
return round(STATIC_BLEND_WEIGHT * static_value + AI_BLEND_WEIGHT * ai_value, 1)
|
|
|
|
|
|
def apply_ai_verdicts(scores: list[FileScore], verdicts: list[AiVerdict]) -> list[FileScore]:
|
|
by_path = {verdict.path: verdict for verdict in verdicts}
|
|
adjusted: list[FileScore] = []
|
|
for score in scores:
|
|
verdict = by_path.get(score.relative)
|
|
if verdict is None:
|
|
adjusted.append(score)
|
|
continue
|
|
origin = _blend(score.origin_score, verdict.origin_score)
|
|
quality = _blend(score.quality_deficit, verdict.quality_deficit)
|
|
adjusted.append(
|
|
FileScore(
|
|
relative=score.relative,
|
|
language=score.language,
|
|
sloc=score.sloc,
|
|
origin_score=origin,
|
|
quality_deficit=quality,
|
|
category=categorize(origin, quality),
|
|
criticality=criticality_for(score.relative),
|
|
signals=score.signals,
|
|
)
|
|
)
|
|
return adjusted
|
|
|
|
|
|
async def _cleanup_event(workspace: Path, stage: str) -> WorkerEvent:
|
|
removed = await asyncio.to_thread(remove_workspace, workspace)
|
|
message = "Workspace deleted, only the persisted report remains" if removed else "Workspace already absent"
|
|
return WorkerEvent(KIND_LOG, message, {"stage": stage, "workspace_removed": removed})
|
|
|
|
|
|
async def run_pipeline(source_url: str, workspace: Path, config: WorkerSettings) -> AsyncIterator[WorkerEvent]:
|
|
yield _stage(STAGE_RESOLVE, f"Resolving source type for {source_url}")
|
|
if not config.allow_private_hosts and is_private_host(source_url):
|
|
yield WorkerEvent(KIND_ERROR, "Refusing to analyze private or loopback hosts", {"stage": STAGE_RESOLVE})
|
|
return
|
|
resolution = await resolve_source(source_url)
|
|
yield WorkerEvent(
|
|
KIND_LOG,
|
|
f"Source classified as {resolution.kind}",
|
|
{"stage": STAGE_RESOLVE, "kind_detected": resolution.kind, "url": resolution.url},
|
|
)
|
|
|
|
yield _stage(STAGE_ACQUIRE, f"Acquiring source into workspace ({resolution.kind})")
|
|
reset_workspace(workspace)
|
|
dom_snapshots: list[DomSnapshot] = []
|
|
try:
|
|
if resolution.kind == KIND_GIT:
|
|
async for progress in clone_repository(resolution.url, workspace):
|
|
yield WorkerEvent(KIND_LOG, progress, {"stage": STAGE_ACQUIRE})
|
|
else:
|
|
async for progress in crawl_website(
|
|
resolution.url, workspace, dom_sink=dom_snapshots, allow_private=config.allow_private_hosts
|
|
):
|
|
yield WorkerEvent(KIND_LOG, progress, {"stage": STAGE_ACQUIRE})
|
|
except (RepositoryTooLargeError, CloneFailedError, RuntimeError) as error:
|
|
yield await _cleanup_event(workspace, STAGE_ACQUIRE)
|
|
yield WorkerEvent(KIND_ERROR, str(error), {"stage": STAGE_ACQUIRE})
|
|
return
|
|
digest = content_hash(workspace)
|
|
yield WorkerEvent(KIND_LOG, f"Workspace content hash {digest[:16]}", {"stage": STAGE_ACQUIRE, "content_hash": digest})
|
|
|
|
yield _stage(STAGE_INVENTORY, "Building file inventory and applying exclusion rules")
|
|
inventory = build_inventory(workspace)
|
|
yield WorkerEvent(
|
|
KIND_LOG,
|
|
f"{len(inventory.analyzable)} files analyzable, {len(inventory.excluded)} excluded "
|
|
f"(vendored, generated, binary, lockfiles, oversize)",
|
|
{
|
|
"stage": STAGE_INVENTORY,
|
|
"analyzable": len(inventory.analyzable),
|
|
"excluded": len(inventory.excluded),
|
|
"excluded_samples": [
|
|
{"path": path, "reason": reason} for path, reason in inventory.excluded[:15]
|
|
],
|
|
},
|
|
)
|
|
if not inventory.analyzable:
|
|
yield await _cleanup_event(workspace, STAGE_INVENTORY)
|
|
yield WorkerEvent(KIND_ERROR, "No analyzable source files found in this source", {"stage": STAGE_INVENTORY})
|
|
return
|
|
template = detect_template(workspace) if config.template_detection else TemplateEvidence()
|
|
if template.markers:
|
|
yield WorkerEvent(
|
|
KIND_SIGNAL,
|
|
f"Starter template provenance detected (score {template.score}/100): "
|
|
+ "; ".join(template.markers[:4]),
|
|
{"stage": STAGE_INVENTORY, "template_score": template.score, "markers": template.markers},
|
|
)
|
|
|
|
yield _stage(STAGE_STATIC, f"Running static multi-signal analysis on {len(inventory.analyzable)} files")
|
|
contexts = []
|
|
for entry in inventory.analyzable:
|
|
context = load_context(entry, inventory.repo)
|
|
if context is not None:
|
|
contexts.append(context)
|
|
else:
|
|
yield WorkerEvent(
|
|
KIND_LOG,
|
|
f"Skipped {entry.relative}: unreadable content",
|
|
{"stage": STAGE_STATIC, "path": entry.relative, "reason": "unreadable"},
|
|
)
|
|
if not contexts:
|
|
yield await _cleanup_event(workspace, STAGE_STATIC)
|
|
yield WorkerEvent(
|
|
KIND_ERROR,
|
|
"Insufficient analyzable content: every candidate file is minified, generated or unreadable. "
|
|
"Abstaining rather than guessing, per methodology.",
|
|
{"stage": STAGE_STATIC, "candidates": len(inventory.analyzable)},
|
|
)
|
|
return
|
|
baselines = compute_repo_baselines(contexts)
|
|
yield WorkerEvent(
|
|
KIND_LOG,
|
|
f"Repo baselines computed over {baselines.file_count} files "
|
|
f"(mean indent deviation {baselines.indent_variance_mean:.2f}, mean comment ratio {baselines.comment_ratio_mean:.2f})",
|
|
{"stage": STAGE_STATIC},
|
|
)
|
|
scores: list[FileScore] = []
|
|
excerpts: dict[str, str] = {}
|
|
source_media_dir = Path(config.media_dir) if config.media_dir else None
|
|
sources_persisted = 0
|
|
for index, context in enumerate(contexts, start=1):
|
|
score = score_file(context, baselines)
|
|
scores.append(score)
|
|
excerpts[score.relative] = context.text[:AI_EXCERPT_CHARS]
|
|
source_name = None
|
|
if score.signals and sources_persisted < SOURCE_CAP_FILES:
|
|
source_name = await asyncio.to_thread(
|
|
_persist_source, source_media_dir, score.relative, context.text
|
|
)
|
|
if source_name:
|
|
sources_persisted += 1
|
|
mode = "fingerprint scan" if context.fingerprint_only else "full analysis"
|
|
yield WorkerEvent(
|
|
KIND_FILE,
|
|
f"Checked {score.relative} ({mode}): origin {score.origin_score}, quality deficit {score.quality_deficit}, {score.category}",
|
|
{
|
|
"stage": STAGE_STATIC,
|
|
"path": score.relative,
|
|
"language": score.language,
|
|
"sloc": score.sloc,
|
|
"origin_score": score.origin_score,
|
|
"quality_deficit": score.quality_deficit,
|
|
"category": score.category,
|
|
"fingerprint_only": context.fingerprint_only,
|
|
"signals": [signal.to_dict() for signal in score.signals[:20]],
|
|
"source": source_name,
|
|
},
|
|
)
|
|
for signal in score.signals:
|
|
if signal.severity == SEVERITY_STRONG:
|
|
yield WorkerEvent(
|
|
KIND_SIGNAL,
|
|
f"Strong signal in {score.relative}:{signal.line} - {signal.title}",
|
|
{"stage": STAGE_STATIC, "path": score.relative, "signal": signal.to_dict()},
|
|
)
|
|
if index % PROGRESS_EVERY_FILES == 0 or index == len(contexts):
|
|
yield WorkerEvent(
|
|
KIND_PROGRESS,
|
|
f"Static analysis {index}/{len(contexts)} files",
|
|
{"stage": STAGE_STATIC, "current": index, "total": len(contexts)},
|
|
)
|
|
|
|
dom_pages = [
|
|
DomPageContext(
|
|
url=snapshot.render.final_url,
|
|
dom=snapshot.dom,
|
|
console_warnings=snapshot.console_warnings,
|
|
console_errors=snapshot.console_errors,
|
|
response_headers=snapshot.response_headers,
|
|
resource_hosts=snapshot.resource_hosts,
|
|
screenshot_bytes=snapshot.screenshot_bytes,
|
|
)
|
|
for snapshot in dom_snapshots
|
|
]
|
|
dom_evidence = aggregate_dom_evidence(dom_pages)
|
|
dom_media_dir = Path(config.media_dir) if config.media_dir else None
|
|
if dom_pages:
|
|
yield _stage(STAGE_DOM, f"Analyzing rendered DOM for AI-builder and AI-slop tells on {len(dom_pages)} page(s)")
|
|
for page in dom_pages:
|
|
screenshot_name = None
|
|
if page.screenshot_bytes is not None and dom_media_dir is not None:
|
|
screenshot_name = await asyncio.to_thread(
|
|
persist_screenshot, page.screenshot_bytes, dom_media_dir, page.url
|
|
)
|
|
page_signals = run_dom_checks(page)
|
|
page_builder, page_builder_confidence = detected_builder_for([page])
|
|
yield WorkerEvent(
|
|
KIND_DOM,
|
|
f"DOM analysis of {page.url}: {len(page_signals)} signals, "
|
|
f"builder {page_builder or 'none'}",
|
|
{
|
|
"stage": STAGE_DOM,
|
|
"url": page.url,
|
|
"detected_builder": page_builder,
|
|
"builder_confidence": page_builder_confidence,
|
|
"signal_count": len(page_signals),
|
|
"signals": [signal.to_dict() for signal in page_signals[:20]],
|
|
"screenshot": screenshot_name,
|
|
},
|
|
)
|
|
|
|
yield _stage(STAGE_AI, "Selecting representative files for AI review")
|
|
llm = LlmClient(config)
|
|
samples = select_samples(scores) if llm.review_available else []
|
|
if not llm.review_available:
|
|
yield WorkerEvent(
|
|
KIND_LOG,
|
|
"AI review disabled or unavailable; static signals remain authoritative",
|
|
{"stage": STAGE_AI},
|
|
)
|
|
yield WorkerEvent(
|
|
KIND_LOG,
|
|
f"AI reviewing {len(samples)} representative files, up to {AI_REVIEW_CONCURRENCY} concurrently "
|
|
f"(deterministic selection, temperature 0)",
|
|
{"stage": STAGE_AI, "samples": [sample.relative for sample in samples]},
|
|
)
|
|
verdicts: list[AiVerdict] = []
|
|
review_semaphore = asyncio.Semaphore(AI_REVIEW_CONCURRENCY)
|
|
|
|
async def review(sample: FileScore) -> tuple[FileScore, AiVerdict | None]:
|
|
async with review_semaphore:
|
|
return sample, await classify_file(llm, sample, excerpts.get(sample.relative, ""))
|
|
|
|
review_tasks = [asyncio.create_task(review(sample)) for sample in samples]
|
|
for index, finished in enumerate(asyncio.as_completed(review_tasks), start=1):
|
|
sample, verdict = await finished
|
|
if verdict is None:
|
|
yield WorkerEvent(
|
|
KIND_LOG,
|
|
f"AI review unavailable for {sample.relative}, static signals remain authoritative",
|
|
{"stage": STAGE_AI, "path": sample.relative},
|
|
)
|
|
else:
|
|
verdicts.append(verdict)
|
|
yield WorkerEvent(
|
|
KIND_AI,
|
|
f"AI verdict {sample.relative}: {verdict.category} ({verdict.ai_probability:.0f}% AI)",
|
|
{
|
|
"stage": STAGE_AI,
|
|
"path": verdict.path,
|
|
"category": verdict.category,
|
|
"origin_score": verdict.origin_score,
|
|
"quality_deficit": verdict.quality_deficit,
|
|
"ai_probability": verdict.ai_probability,
|
|
"reasoning": verdict.reasoning,
|
|
"notable_signals": verdict.notable_signals,
|
|
},
|
|
)
|
|
yield WorkerEvent(
|
|
KIND_PROGRESS,
|
|
f"AI review {index}/{len(samples)} files",
|
|
{"stage": STAGE_AI, "current": index, "total": len(samples)},
|
|
)
|
|
|
|
yield _stage(STAGE_IMAGES, "Reviewing images for AI-generated content")
|
|
image_verdicts: list[ImageVerdict] = []
|
|
image_stats: dict[str, object] = {"count": 0, "mean_ai_probability": 0.0, "grade": "n/a", "ai_generated_count": 0}
|
|
if not llm.vision_available:
|
|
yield WorkerEvent(KIND_LOG, "Vision backend unavailable; skipping image analysis", {"stage": STAGE_IMAGES})
|
|
else:
|
|
images = await asyncio.to_thread(collect_images, workspace, config.image_max_count)
|
|
if not images:
|
|
yield WorkerEvent(KIND_LOG, "No qualifying images found to review", {"stage": STAGE_IMAGES})
|
|
else:
|
|
yield WorkerEvent(
|
|
KIND_LOG,
|
|
f"Reviewing {len(images)} images with the vision model, up to {IMAGE_CONCURRENCY} concurrently "
|
|
f"(deterministic sample, capped at {config.image_max_count})",
|
|
{"stage": STAGE_IMAGES, "images": [str(path.relative_to(workspace)) for path in images]},
|
|
)
|
|
media_dir = Path(config.media_dir) if config.media_dir else None
|
|
thumbs: dict[str, str | None] = {}
|
|
for image_path in images:
|
|
relative = str(image_path.relative_to(workspace))
|
|
thumb = None
|
|
if media_dir is not None:
|
|
thumb = await asyncio.to_thread(make_thumbnail, image_path, media_dir, relative)
|
|
thumbs[relative] = thumb
|
|
yield WorkerEvent(
|
|
KIND_LOG,
|
|
f"Reviewing image {relative}",
|
|
{"stage": STAGE_IMAGES, "path": relative, "thumb": thumb},
|
|
)
|
|
image_semaphore = asyncio.Semaphore(IMAGE_CONCURRENCY)
|
|
|
|
async def review_image(image_path: Path) -> ImageVerdict | None:
|
|
async with image_semaphore:
|
|
return await classify_image(llm, workspace, image_path)
|
|
|
|
image_tasks = [asyncio.create_task(review_image(image_path)) for image_path in images]
|
|
for index, finished in enumerate(asyncio.as_completed(image_tasks), start=1):
|
|
verdict = await finished
|
|
if verdict is None:
|
|
continue
|
|
image_verdicts.append(verdict)
|
|
yield WorkerEvent(
|
|
KIND_IMAGE,
|
|
f"Image {verdict.relative}: grade {verdict.grade}, {verdict.verdict} "
|
|
f"({verdict.ai_probability:.0f}% AI) - {verdict.image_kind}",
|
|
{
|
|
"stage": STAGE_IMAGES,
|
|
"path": verdict.relative,
|
|
"ai_probability": verdict.ai_probability,
|
|
"grade": verdict.grade,
|
|
"verdict": verdict.verdict,
|
|
"image_kind": verdict.image_kind,
|
|
"tells": verdict.tells,
|
|
"description": verdict.description,
|
|
"thumb": thumbs.get(verdict.relative),
|
|
},
|
|
)
|
|
yield WorkerEvent(
|
|
KIND_PROGRESS,
|
|
f"Image review {index}/{len(images)}",
|
|
{"stage": STAGE_IMAGES, "current": index, "total": len(images)},
|
|
)
|
|
image_stats = image_summary(image_verdicts)
|
|
yield WorkerEvent(
|
|
KIND_PROGRESS,
|
|
f"Image review complete: {image_stats['count']} images, "
|
|
f"{image_stats['ai_generated_count']} look AI-generated, grade {image_stats['grade']}",
|
|
{"stage": STAGE_IMAGES, **image_stats},
|
|
)
|
|
|
|
yield _stage(STAGE_SCORE, "Aggregating per-file scores into repository verdict")
|
|
static_scores = aggregate(scores)
|
|
blended = bool(verdicts)
|
|
adjusted = apply_ai_verdicts(scores, verdicts) if blended else scores
|
|
final_scores = aggregate(adjusted)
|
|
image_influenced = False
|
|
if image_verdicts:
|
|
final_scores = adjust_for_images(final_scores, float(image_stats["mean_ai_probability"]))
|
|
image_influenced = True
|
|
final_scores = adjust_for_dom_signals(final_scores, dom_evidence)
|
|
final_scores = adjust_for_template(final_scores, template.score)
|
|
score_payload = {
|
|
"stage": STAGE_SCORE,
|
|
"static": repo_scores_to_dict(static_scores),
|
|
"blended_with_ai": blended,
|
|
"template_score": template.score,
|
|
"template_markers": template.markers,
|
|
**repo_scores_to_dict(final_scores),
|
|
"files_total": len(inventory.analyzable) + len(inventory.excluded),
|
|
"files_analyzed": len(scores),
|
|
"content_hash": digest,
|
|
"source_kind": resolution.kind,
|
|
"image_review": image_stats,
|
|
"image_influenced": image_influenced,
|
|
"detected_builder": dom_evidence.detected_builder,
|
|
"dom_slop_score": dom_evidence.score,
|
|
}
|
|
yield WorkerEvent(
|
|
KIND_SCORE,
|
|
f"Final verdict: grade {final_scores.grade}, {final_scores.category}, "
|
|
f"{final_scores.human_percent}% human / {final_scores.ai_percent}% AI, confidence {final_scores.confidence}",
|
|
score_payload,
|
|
)
|
|
|
|
yield _stage(STAGE_REPORT, "Generating final report with findings")
|
|
markdown, model_used = await generate_report(
|
|
llm,
|
|
source_url,
|
|
resolution.kind,
|
|
final_scores,
|
|
adjusted,
|
|
verdicts,
|
|
len(inventory.excluded),
|
|
image_verdicts,
|
|
image_stats,
|
|
template,
|
|
dom_evidence,
|
|
)
|
|
await llm.aclose()
|
|
yield WorkerEvent(
|
|
KIND_REPORT,
|
|
f"Report generated via {model_used} ({len(markdown)} chars)",
|
|
{"stage": STAGE_REPORT, "markdown": markdown, "model_used": model_used},
|
|
)
|
|
yield await _cleanup_event(workspace, STAGE_REPORT)
|
|
yield WorkerEvent(KIND_DONE, "Analysis complete", score_payload)
|