diff --git a/graphite_app/graphite_canvas/graphite_canvas_base.py b/graphite_app/graphite_canvas/graphite_canvas_base.py index 528d13d..9a072ec 100644 --- a/graphite_app/graphite_canvas/graphite_canvas_base.py +++ b/graphite_app/graphite_canvas/graphite_canvas_base.py @@ -192,12 +192,9 @@ def iter_scene_connection_lists(scene): "code_sandbox_connections", "web_connections", "conversation_connections", - "reasoning_connections", "group_summary_connections", "html_connections", "artifact_connections", - "workflow_connections", - "graph_diff_connections", ) for name in list_names: diff --git a/graphite_app/graphite_plugins/code_review/__init__.py b/graphite_app/graphite_plugins/code_review/__init__.py deleted file mode 100644 index 794f407..0000000 --- a/graphite_app/graphite_plugins/code_review/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Code Review Agent plugin: Qt-free scoring engine extracted from the widget file.""" diff --git a/graphite_app/graphite_plugins/code_review/scoring.py b/graphite_app/graphite_plugins/code_review/scoring.py deleted file mode 100644 index 7b1a492..0000000 --- a/graphite_app/graphite_plugins/code_review/scoring.py +++ /dev/null @@ -1,723 +0,0 @@ -"""Deterministic scoring/rubric engine for the Code Review Agent plugin. - -Extracted from graphite_plugin_code_review.py. This module has zero Qt -dependencies, so it's directly unit-testable without constructing any widget. - -Deliberately NOT merged with QualityGateAnalyzer's equivalent engine in -graphite_plugin_quality_gate.py, even though they share the same JSON-parsing -skeleton (already factored out into graphite_plugins/common/llm_json.py) - each -rubric's actual scoring weights, categories, and fallback heuristics are genuinely -different domain logic, not incidental duplication. See -doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.2 for why unifying the rubrics -themselves (as opposed to the boilerplate around them) is out of scope here. -""" - -import ast -import re - -import graphite_config as config -from graphite_plugins.common.llm_json import call_llm_and_parse_json, extract_json_object - - -REVIEW_CATEGORY_WEIGHTS = { - "correctness": 24, - "reliability": 16, - "security": 14, - "maintainability": 14, - "readability": 10, - "testing": 10, - "performance": 6, - "architecture": 6, -} - -REVIEW_CATEGORY_LABELS = { - "correctness": "Correctness", - "reliability": "Reliability", - "security": "Security", - "maintainability": "Maintainability", - "readability": "Readability", - "testing": "Testing", - "performance": "Performance", - "architecture": "Architecture", -} - -SEVERITY_ORDER = { - "critical": 0, - "high": 1, - "medium": 2, - "low": 3, - "info": 4, -} - -TEXT_FILE_EXCLUSION_SUFFIXES = ( - ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp", ".pdf", - ".zip", ".tar", ".gz", ".7z", ".rar", ".mp3", ".wav", ".ogg", ".mp4", ".mov", - ".avi", ".webm", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".exe", ".dll", - ".so", ".dylib", ".class", ".jar", ".pyc", ".pyd", ".bin", ".dat", ".db", -) - -CODE_REVIEW_METRIC_MARKDOWN = """## Deterministic Review Metric - -This plugin uses a fixed, repeatable rubric before the model is allowed to grade the file. - -### Preflight Gate - -1. Confirm the source is present, readable, and large enough to review. -2. Identify the file's likely language, runtime, and execution boundary. -3. Note whether the review sees the full file or a truncated excerpt. -4. Identify external assumptions: imports, environment variables, network calls, filesystem access, framework hooks. -5. Decide whether there is enough evidence to score each category fairly. If not, mark the gap instead of guessing. - -### Required Inspection Sequence - -1. Trace the happy-path control flow from input to output. -2. Check edge cases, null/empty states, and failure branches. -3. Inspect error handling, retries, cleanup, and state consistency. -4. Inspect secrets, auth, injection risk, unsafe execution, and trust boundaries. -5. Inspect data contracts, side effects, and dependency assumptions. -6. Inspect readability, cohesion, naming, duplication, and complexity. -7. Inspect tests, observability, and how the code could be validated. -8. Inspect performance hotspots only where the visible code suggests a real risk. -9. Separate high-confidence errors from lower-confidence review findings. -10. Produce scores from the fixed weights below instead of ad hoc scoring. - -### Weighted Scorecard - -- Correctness: 24% -- Reliability: 16% -- Security: 14% -- Maintainability: 14% -- Readability: 10% -- Testing: 10% -- Performance: 6% -- Architecture: 6% - -### Verdict Gates - -- `Strong`: weighted score >= 78, no critical errors, no high-severity findings. -- `Needs Revision`: weighted score 60-77, or at least one high-confidence error, or at least one high-severity finding. -- `Not Ready`: weighted score < 60, or at least one critical error. - -### Output Contract - -- Overview: short executive review of what matters most. -- Review Findings: evidence-backed issues ordered by severity. -- Errors Found: only high-confidence bugs / faults / security defects. -- Code Quality Report: deterministic weighted score plus release risk. -""" - - -def _clean_text(value, limit=None): - text = str(value or "").strip() - text = re.sub(r"\n{3,}", "\n\n", text) - if limit and len(text) > limit: - return text[: limit - 3].rstrip() + "..." - return text - - -def _clamp_score(value, default=70): - try: - numeric = int(round(float(value))) - except (TypeError, ValueError): - numeric = default - return max(0, min(100, numeric)) - - -def _severity_key(value): - severity = _clean_text(value, limit=20).lower() - return severity if severity in SEVERITY_ORDER else "medium" - - -def _titleize_key(value): - cleaned = re.sub(r"[_-]+", " ", _clean_text(value, limit=80)).strip() - return cleaned.title() if cleaned else "General" - - -def _looks_like_python(source_state, source_text): - path = ( - source_state.get("path") - or source_state.get("local_path") - or source_state.get("label") - or "" - ).lower() - if path.endswith(".py"): - return True - tokens = ("def ", "class ", "import ", "from ", "async def ") - return sum(1 for token in tokens if token in source_text) >= 2 - - -def _decode_text_bytes(raw_bytes): - for encoding in ("utf-8", "utf-8-sig", "cp1252", "latin-1"): - try: - return raw_bytes.decode(encoding) - except UnicodeDecodeError: - continue - return raw_bytes.decode("utf-8", errors="replace") - - -def _prepare_numbered_source(source_text, max_chars=40000): - lines = source_text.splitlines() or [source_text] - total_lines = len(lines) - visible_lines = [] - current_length = 0 - - for index, line in enumerate(lines, start=1): - numbered_line = f"{index:04d}: {line}" - projected = current_length + len(numbered_line) + 1 - if visible_lines and projected > max_chars: - break - visible_lines.append(numbered_line) - current_length = projected - - truncated = len(visible_lines) < total_lines - return "\n".join(visible_lines), truncated, total_lines, len(visible_lines) - - -def _is_reviewable_repo_path(path_text): - lowered = path_text.lower() - return not lowered.endswith(TEXT_FILE_EXCLUSION_SUFFIXES) - - -def _source_origin_label(source_state): - origin = source_state.get("origin", "") - if origin == "github": - repo = source_state.get("repo", "") - branch = source_state.get("branch", "") - file_path = source_state.get("path", "") - parts = [part for part in (repo, branch, file_path) if part] - return f"GitHub: {' / '.join(parts)}" if parts else "GitHub file" - if origin == "local": - return f"Local File: {source_state.get('local_path', '') or source_state.get('label', 'Loaded file')}" - if origin == "manual": - return "Manual / pasted source" - return "No source selected" - - -def _compact_label_text(text, limit=34): - text = (text or "").strip() - if len(text) <= limit: - return text - return text[: max(0, limit - 3)].rstrip() + "..." - - -def _source_scope_summary(payload): - source_state = payload.get("source_state", {}) - context_text = _clean_text(payload.get("review_context", ""), limit=400) - numbered_source = payload.get("source_for_model", "") - summary_lines = [ - f"- Source: {_source_origin_label(source_state)}", - f"- Total lines loaded: {payload.get('total_lines', 0)}", - f"- Visible lines reviewed by the model: {payload.get('visible_lines', 0)}", - f"- Full file visible to model: {'No' if payload.get('source_truncated') else 'Yes'}", - ] - if context_text: - summary_lines.append(f"- Review context: {context_text}") - if source_state.get("edited"): - summary_lines.append("- Loaded source was manually edited inside the plugin before review.") - if not numbered_source.strip(): - summary_lines.append("- Source excerpt: unavailable") - return "\n".join(summary_lines) - - -class CodeReviewAnalyzer: - SYSTEM_PROMPT = f""" -You are Graphlink's Code Review Agent. - -Your job is to produce a disciplined, repeatable single-file code review. -You must use the exact checklist and weighted scoring model below instead of inventing a new rubric each time. - -{CODE_REVIEW_METRIC_MARKDOWN} - -Rules: -1. Be evidence-driven. Do not invent dependencies, tests, runtime behavior, or unseen files. -2. Separate high-confidence errors from broader review findings. -3. High-confidence errors must be concrete faults such as syntax problems, likely runtime failures, security defects, or clearly broken logic. -4. Review findings can include maintainability, readability, testing, or architectural concerns, but still require visible evidence. -5. If the source is truncated, only review what is visible and explicitly mention the visibility limit. -6. Avoid low-value stylistic nitpicks unless they materially affect readability, safety, maintainability, or correctness. -7. Output valid JSON only. No markdown fences, no commentary outside the JSON object. - -Return exactly this shape: -{{ - "title": "Short review title", - "overview": "2-4 sentence executive summary", - "confidence": "high", - "preflight_checks": [ - {{ - "check": "Source completeness", - "status": "pass", - "details": "What was verified before scoring" - }} - ], - "review_findings": [ - {{ - "severity": "medium", - "category": "maintainability", - "title": "Short finding title", - "evidence": "Visible code evidence only", - "impact": "Why this matters", - "recommendation": "Concrete improvement" - }} - ], - "errors_found": [ - {{ - "severity": "high", - "kind": "runtime", - "title": "Short error title", - "evidence": "Visible code evidence only", - "fix": "Concrete remediation" - }} - ], - "category_scores": {{ - "correctness": 80, - "reliability": 78, - "security": 86, - "maintainability": 74, - "readability": 81, - "testing": 62, - "performance": 76, - "architecture": 73 - }}, - "quality_summary": "Short synthesis that aligns with the findings and scores" -}} -""" - - def _extract_json(self, raw_text): - # Delegates to the shared regex (doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section - # 1.6/3.5) - kept as a wrapper so this method's existing call site is unchanged. - return extract_json_object(raw_text) - - def _normalize_preflight(self, payload, checks): - default_checks = [ - { - "check": "Source loaded", - "status": "pass" if payload.get("source_text", "").strip() else "fail", - "details": "The plugin has source text to inspect." if payload.get("source_text", "").strip() else "No source text was supplied.", - }, - { - "check": "Language/runtime identified", - "status": "pass" if payload.get("source_state", {}).get("label") or payload.get("source_text", "").strip() else "warn", - "details": "The reviewer can infer likely runtime context from the file path or visible syntax.", - }, - { - "check": "Visibility limit assessed", - "status": "warn" if payload.get("source_truncated") else "pass", - "details": "The review only sees a truncated excerpt." if payload.get("source_truncated") else "The full file is available to the reviewer.", - }, - { - "check": "External assumptions noted", - "status": "pass", - "details": "Imports, filesystem access, network calls, and framework assumptions must be treated as explicit review surface.", - }, - { - "check": "Scoring evidence threshold", - "status": "pass" if payload.get("visible_lines", 0) >= 5 else "warn", - "details": "There is enough visible source to score the file with reasonable confidence." if payload.get("visible_lines", 0) >= 5 else "The file is sparse, so scoring confidence is lower.", - }, - ] - - normalized = [] - for item in checks or []: - if not isinstance(item, dict): - continue - status = _clean_text(item.get("status"), limit=20).lower() - if status not in {"pass", "warn", "fail"}: - status = "warn" - normalized.append({ - "check": _clean_text(item.get("check"), limit=120) or "Unnamed preflight check", - "status": status, - "details": _clean_text(item.get("details"), limit=280) or "No details supplied.", - }) - - if len(normalized) < 5: - for default_item in default_checks: - if not any(existing["check"] == default_item["check"] for existing in normalized): - normalized.append(default_item) - return normalized[:8] - - def _normalize_findings(self, findings, is_error_list=False): - normalized = [] - for item in findings or []: - if not isinstance(item, dict): - continue - severity = _severity_key(item.get("severity")) - title = _clean_text(item.get("title"), limit=120) - evidence = _clean_text(item.get("evidence"), limit=420) - if not title or not evidence: - continue - - normalized_item = { - "severity": severity, - "title": title, - "evidence": evidence, - } - - if is_error_list: - normalized_item["kind"] = _titleize_key(item.get("kind") or item.get("category") or "runtime") - normalized_item["fix"] = _clean_text(item.get("fix"), limit=320) or "Address the visible root cause and re-run validation." - else: - normalized_item["category"] = _titleize_key(item.get("category") or "general") - normalized_item["impact"] = _clean_text(item.get("impact"), limit=320) or "This issue reduces confidence in the file's quality or safety." - normalized_item["recommendation"] = _clean_text(item.get("recommendation"), limit=320) or "Tighten the implementation and add verification for this path." - - normalized.append(normalized_item) - - normalized.sort(key=lambda item: (SEVERITY_ORDER.get(item["severity"], 5), item["title"])) - return normalized[:10] - - def _normalize_scores(self, parsed_scores): - scores = {} - for key in REVIEW_CATEGORY_WEIGHTS: - scores[key] = _clamp_score((parsed_scores or {}).get(key), default=72) - return scores - - def _compute_weighted_score(self, category_scores): - weighted_total = 0.0 - for key, weight in REVIEW_CATEGORY_WEIGHTS.items(): - weighted_total += category_scores[key] * (weight / 100.0) - return int(round(weighted_total)) - - def _derive_verdict(self, overall_score, findings, errors): - critical_errors = sum(1 for item in errors if item["severity"] == "critical") - high_errors = sum(1 for item in errors if item["severity"] == "high") - high_findings = sum(1 for item in findings if item["severity"] in {"critical", "high"}) - - if critical_errors > 0 or overall_score < 60: - verdict = "not_ready" - elif high_errors > 0 or high_findings > 0 or overall_score < 78: - verdict = "needs_revision" - else: - verdict = "strong" - - if critical_errors > 0 or overall_score < 60: - risk = "high" - elif high_errors > 0 or overall_score < 78: - risk = "medium" - else: - risk = "low" - return verdict, risk - - def _build_overview_markdown(self, normalized, payload): - preflight_lines = [] - for item in normalized["preflight_checks"]: - status = item["status"].upper() - preflight_lines.append(f"- `{status}` {item['check']}: {item['details']}") - - return "\n".join([ - "## Review Overview", - "", - normalized["overview"], - "", - "### Review Scope", - _source_scope_summary(payload), - "", - "### Preflight Checklist", - *preflight_lines, - ]) - - def _build_findings_markdown(self, normalized): - findings = normalized["review_findings"] - if not findings: - return "\n".join([ - "## Review Findings", - "", - "No additional evidence-backed review findings were identified beyond the high-confidence errors list.", - ]) - - lines = ["## Review Findings", ""] - for index, finding in enumerate(findings, start=1): - lines.extend([ - f"### {index}. [{finding['severity'].upper()}] {finding['title']}", - f"- Category: {finding['category']}", - f"- Evidence: {finding['evidence']}", - f"- Impact: {finding['impact']}", - f"- Recommendation: {finding['recommendation']}", - "", - ]) - return "\n".join(lines).rstrip() - - def _build_errors_markdown(self, normalized): - errors = normalized["errors_found"] - if not errors: - return "\n".join([ - "## Errors Found", - "", - "No high-confidence errors were identified from the visible source.", - ]) - - lines = ["## Errors Found", ""] - for index, error in enumerate(errors, start=1): - lines.extend([ - f"### {index}. [{error['severity'].upper()}] {error['title']}", - f"- Kind: {error['kind']}", - f"- Evidence: {error['evidence']}", - f"- Fix: {error['fix']}", - "", - ]) - return "\n".join(lines).rstrip() - - def _build_quality_markdown(self, normalized): - score_lines = [] - for key in REVIEW_CATEGORY_WEIGHTS: - score_lines.append( - f"- {REVIEW_CATEGORY_LABELS[key]} ({REVIEW_CATEGORY_WEIGHTS[key]}%): {normalized['category_scores'][key]}/100" - ) - - verdict_label = normalized["verdict"].replace("_", " ").title() - confidence_label = normalized["confidence"].title() - risk_label = normalized["risk_level"].title() - - return "\n".join([ - "## Code Quality Report", - "", - f"- Deterministic weighted score: {normalized['quality_score']}/100", - f"- Verdict: {verdict_label}", - f"- Confidence: {confidence_label}", - f"- Release risk: {risk_label}", - "", - "### Weighted Scorecard", - *score_lines, - "", - "### Summary", - normalized["quality_summary"], - "", - "### Verdict Logic", - "- `Strong`: score >= 78, no critical errors, no high-severity findings.", - "- `Needs Revision`: score 60-77, or any high-confidence error, or any high-severity finding.", - "- `Not Ready`: score < 60, or any critical error.", - ]) - - def _build_combined_markdown(self, overview_markdown, findings_markdown, errors_markdown, quality_markdown): - return "\n\n".join([ - overview_markdown, - findings_markdown, - errors_markdown, - quality_markdown, - ]) - - def _build_quality_summary(self, normalized): - findings_count = len(normalized["review_findings"]) - errors_count = len(normalized["errors_found"]) - strongest_category = max(normalized["category_scores"], key=lambda key: normalized["category_scores"][key]) - weakest_category = min(normalized["category_scores"], key=lambda key: normalized["category_scores"][key]) - - summary = _clean_text(normalized.get("quality_summary"), limit=420) - if summary: - return summary - - return ( - f"The file scores strongest in {REVIEW_CATEGORY_LABELS[strongest_category].lower()} " - f"and weakest in {REVIEW_CATEGORY_LABELS[weakest_category].lower()}. " - f"The review surfaced {findings_count} broader findings and {errors_count} high-confidence errors." - ) - - def _fallback_review(self, payload, exception_text=None): - source_text = payload.get("source_text", "") - source_state = payload.get("source_state", {}) - findings = [] - errors = [] - scores = {key: 82 for key in REVIEW_CATEGORY_WEIGHTS} - - def add_finding(severity, category, title, evidence, impact, recommendation): - findings.append({ - "severity": severity, - "category": category, - "title": title, - "evidence": evidence, - "impact": impact, - "recommendation": recommendation, - }) - - def add_error(severity, kind, title, evidence, fix): - errors.append({ - "severity": severity, - "kind": kind, - "title": title, - "evidence": evidence, - "fix": fix, - }) - - if _looks_like_python(source_state, source_text): - try: - ast.parse(source_text) - except SyntaxError as exc: - evidence = f"Python parser raised a syntax error near line {exc.lineno}: {exc.msg}." - add_error( - "critical", - "Syntax", - "Python syntax error prevents execution", - evidence, - "Fix the syntax error before running or reviewing downstream behavior.", - ) - scores["correctness"] = min(scores["correctness"], 25) - scores["reliability"] = min(scores["reliability"], 30) - scores["maintainability"] = min(scores["maintainability"], 38) - - if re.search(r"(api[_-]?key|secret|token|password)\s*=\s*['\"][^'\"]+['\"]", source_text, re.IGNORECASE): - add_error( - "high", - "Security", - "Hard-coded secret-like value detected", - "The file appears to assign a literal value to a secret-like variable name.", - "Move the value to secure configuration or environment-based secret management.", - ) - scores["security"] = min(scores["security"], 35) - scores["maintainability"] = min(scores["maintainability"], 55) - - if re.search(r"\b(eval|exec)\s*\(", source_text): - add_finding( - "high", - "security", - "Dynamic code execution increases risk", - "The file calls `eval(...)` or `exec(...)` directly.", - "Dynamic execution expands injection and debugging risk.", - "Replace dynamic execution with explicit parsing or a constrained execution strategy.", - ) - scores["security"] = min(scores["security"], 40) - - if re.search(r"subprocess\.(Popen|run)\(.*shell\s*=\s*True", source_text, re.IGNORECASE | re.DOTALL) or "os.system(" in source_text: - add_finding( - "high", - "security", - "Shell execution path requires strict input control", - "The file invokes a shell command path from code.", - "Shell execution becomes dangerous if any untrusted input reaches the command.", - "Prefer argument lists, validate inputs, and avoid shell invocation when possible.", - ) - scores["security"] = min(scores["security"], 45) - - if re.search(r"except\s*:\s*\n", source_text): - add_finding( - "medium", - "reliability", - "Bare exception handler hides root causes", - "The file contains a bare `except:` block.", - "Bare exception handling can swallow unrelated failures and make debugging harder.", - "Catch only expected exception types and log or re-raise unexpected ones.", - ) - scores["reliability"] = min(scores["reliability"], 60) - - if re.search(r"except\s+Exception\s*:\s*pass", source_text): - add_error( - "high", - "Reliability", - "Exception is silently discarded", - "The file uses `except Exception: pass`, which hides execution failures.", - "Handle the exception explicitly or surface the failure so the caller can react.", - ) - scores["reliability"] = min(scores["reliability"], 42) - - if re.search(r"\b(TODO|FIXME)\b", source_text): - add_finding( - "low", - "maintainability", - "Outstanding TODO or FIXME markers remain in the file", - "The visible source still contains TODO/FIXME markers.", - "Open TODO markers often indicate unfinished edge cases or deferred cleanup.", - "Either resolve the pending work or convert the note into a tracked issue with clear ownership.", - ) - scores["maintainability"] = min(scores["maintainability"], 72) - - if re.search(r"\b(print|console\.log)\s*\(", source_text): - add_finding( - "low", - "readability", - "Debug logging remains in the file", - "The visible source includes raw debug logging calls.", - "Ad hoc logging can add noise and make production behavior harder to reason about.", - "Replace debug prints with structured logging or remove them before release.", - ) - scores["readability"] = min(scores["readability"], 74) - - long_line_count = sum(1 for line in source_text.splitlines() if len(line) > 140) - if long_line_count >= 5: - add_finding( - "low", - "readability", - "Several lines exceed a maintainable width", - f"The file contains {long_line_count} lines longer than 140 characters.", - "Very long lines usually hide complexity and make review and debugging slower.", - "Break long expressions into named steps or helper functions.", - ) - scores["readability"] = min(scores["readability"], 70) - - if payload.get("source_truncated"): - scores["architecture"] = min(scores["architecture"], 74) - scores["testing"] = min(scores["testing"], 74) - - if not findings and not errors: - overview = "The visible file is structurally clean in this heuristic pass, with no immediately obvious high-confidence defects. A full model-driven review should still be preferred for architectural nuance and testability judgment." - else: - overview = "The fallback review identified concrete issues in the visible source. The most important next step is to address the highest-severity items before relying on the file in a production path." - - if exception_text: - overview += f" A heuristic fallback was used because the model review could not be completed cleanly: {_clean_text(exception_text, limit=120)}." - - return { - "title": "Code Review", - "overview": overview, - "confidence": "low" if exception_text else "medium", - "preflight_checks": self._normalize_preflight(payload, []), - "review_findings": findings, - "errors_found": errors, - "category_scores": scores, - "quality_summary": "", - } - - def _normalize_response(self, parsed, payload): - if not isinstance(parsed, dict): - parsed = {} - - normalized = { - "title": _clean_text(parsed.get("title"), limit=120) or "Code Review", - "overview": _clean_text(parsed.get("overview"), limit=600) or "The file was reviewed against a fixed engineering quality rubric.", - "confidence": _clean_text(parsed.get("confidence"), limit=20).lower() or "medium", - "preflight_checks": self._normalize_preflight(payload, parsed.get("preflight_checks", [])), - "review_findings": self._normalize_findings(parsed.get("review_findings", [])), - "errors_found": self._normalize_findings(parsed.get("errors_found", []), is_error_list=True), - "category_scores": self._normalize_scores(parsed.get("category_scores", {})), - "quality_summary": _clean_text(parsed.get("quality_summary"), limit=420), - } - - if normalized["confidence"] not in {"low", "medium", "high"}: - normalized["confidence"] = "medium" - - normalized["quality_score"] = self._compute_weighted_score(normalized["category_scores"]) - normalized["verdict"], normalized["risk_level"] = self._derive_verdict( - normalized["quality_score"], - normalized["review_findings"], - normalized["errors_found"], - ) - normalized["quality_summary"] = self._build_quality_summary(normalized) - normalized["finding_count"] = len(normalized["review_findings"]) - normalized["error_count"] = len(normalized["errors_found"]) - normalized["metric_markdown"] = CODE_REVIEW_METRIC_MARKDOWN - normalized["overview_markdown"] = self._build_overview_markdown(normalized, payload) - normalized["findings_markdown"] = self._build_findings_markdown(normalized) - normalized["errors_markdown"] = self._build_errors_markdown(normalized) - normalized["quality_report_markdown"] = self._build_quality_markdown(normalized) - normalized["review_markdown"] = self._build_combined_markdown( - normalized["overview_markdown"], - normalized["findings_markdown"], - normalized["errors_markdown"], - normalized["quality_report_markdown"], - ) - return normalized - - def get_response(self, payload): - user_prompt = "\n".join([ - "Review the following source file using the deterministic code review metric.", - "", - _source_scope_summary(payload), - "", - "### Source For Review", - payload.get("source_for_model", "") or "[No source loaded]", - ]) - - try: - parsed = call_llm_and_parse_json(self.SYSTEM_PROMPT, user_prompt, task=config.TASK_CHAT) - except Exception as exc: - parsed = self._fallback_review(payload, str(exc)) - return self._normalize_response(parsed, payload) - - diff --git a/graphite_app/graphite_plugins/common/combo.py b/graphite_app/graphite_plugins/common/combo.py new file mode 100644 index 0000000..84099b1 --- /dev/null +++ b/graphite_app/graphite_plugins/common/combo.py @@ -0,0 +1,324 @@ +"""Shared popup-style combo box for plugin nodes rendered inside the graphics scene. + +A plain QComboBox pops its list using a top-level native popup that is positioned +relative to the on-screen widget. That breaks for a combo living inside a +QGraphicsProxyWidget (the popup lands in the wrong place and does not follow the +canvas). ComboPopup/PopupComboBox replace that with a framed QListWidget popup that is +anchored by mapping the combo's rect through the proxy -> scene -> view -> global +coordinate chain, so it lines up correctly on the canvas. + +Extracted verbatim from graphite_plugin_code_review.py (where it was named +CodeReviewComboPopup/CodeReviewPopupComboBox) so it can outlive the Code Review plugin's +removal - Gitlink still depends on it. This module has no plugin-specific logic. +""" + +from PySide6.QtCore import ( + QEvent, + QPoint, + QPointF, + QRect, + QSize, + Qt, + QTimer, + Signal, +) +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import ( + QAbstractItemView, + QApplication, + QComboBox, + QFrame, + QListWidget, + QListWidgetItem, + QVBoxLayout, +) + + +class ComboPopup(QFrame): + item_selected = Signal(int, str) + popup_closed = Signal() + + def __init__(self, parent=None): + super().__init__( + parent, + Qt.WindowType.Popup + | Qt.WindowType.FramelessWindowHint + | Qt.WindowType.NoDropShadowWindowHint, + ) + self.setObjectName("pluginComboPopupFrame") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self._owner_combo = None + self._close_monitor_active = False + + outer_layout = QVBoxLayout(self) + outer_layout.setContentsMargins(0, 0, 0, 0) + outer_layout.setSpacing(0) + + self.shell = QFrame() + self.shell.setObjectName("pluginComboPopupShell") + outer_layout.addWidget(self.shell) + + shell_layout = QVBoxLayout(self.shell) + shell_layout.setContentsMargins(4, 4, 4, 4) + shell_layout.setSpacing(0) + + self.list_widget = QListWidget() + self.list_widget.setObjectName("pluginComboPopupList") + self.list_widget.setFrameShape(QFrame.Shape.NoFrame) + self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.list_widget.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.list_widget.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.list_widget.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.list_widget.setSpacing(2) + self.list_widget.setMouseTracking(True) + self.list_widget.itemClicked.connect(self._emit_item_selection) + self.list_widget.itemActivated.connect(self._emit_item_selection) + shell_layout.addWidget(self.list_widget) + + self.apply_style() + + def apply_style(self, accent_color="#2d6fa3"): + self.setStyleSheet( + f""" + QFrame#pluginComboPopupFrame {{ + background-color: #1f2327; + border: 1px solid #353b43; + border-radius: 10px; + }} + QFrame#pluginComboPopupShell {{ + background: transparent; + border: none; + }} + QListWidget#pluginComboPopupList {{ + background: transparent; + color: #ffffff; + border: none; + outline: none; + padding: 2px; + }} + QListWidget#pluginComboPopupList::item {{ + background: transparent; + color: #ffffff; + border: none; + border-radius: 6px; + min-height: 26px; + padding: 6px 10px; + }} + QListWidget#pluginComboPopupList::item:hover {{ + background-color: #2a3037; + }} + QListWidget#pluginComboPopupList::item:selected {{ + background-color: {accent_color}; + color: #ffffff; + }} + QListWidget#pluginComboPopupList::item:selected:hover {{ + background-color: {accent_color}; + color: #ffffff; + }} + """ + ) + + def populate_from_combo(self, combo): + current_index = combo.currentIndex() + current_text = combo.currentText() + + self.list_widget.clear() + for index in range(combo.count()): + text = combo.itemText(index) + item = QListWidgetItem(text) + item.setData(Qt.ItemDataRole.UserRole, index) + self.list_widget.addItem(item) + + if current_index < 0 and current_text: + current_index = combo.findText(current_text) + + if 0 <= current_index < self.list_widget.count(): + self.list_widget.setCurrentRow(current_index) + current_item = self.list_widget.item(current_index) + if current_item is not None: + self.list_widget.scrollToItem( + current_item, + QAbstractItemView.ScrollHint.PositionAtCenter, + ) + else: + self.list_widget.clearSelection() + + def _screen_anchor_rect(self, combo): + host_widget = combo.window() + proxy = None + if host_widget is not None and hasattr(host_widget, "graphicsProxyWidget"): + proxy = host_widget.graphicsProxyWidget() + + if proxy is not None and proxy.scene() is not None and proxy.scene().views(): + view = proxy.scene().views()[0] + top_left_host = combo.mapTo(host_widget, QPoint(0, 0)) + top_right_host = combo.mapTo(host_widget, QPoint(combo.width(), 0)) + bottom_left_host = combo.mapTo(host_widget, QPoint(0, combo.height())) + + top_left_scene = proxy.mapToScene(QPointF(top_left_host)) + top_right_scene = proxy.mapToScene(QPointF(top_right_host)) + bottom_left_scene = proxy.mapToScene(QPointF(bottom_left_host)) + + top_left_view = view.mapFromScene(top_left_scene) + top_right_view = view.mapFromScene(top_right_scene) + bottom_left_view = view.mapFromScene(bottom_left_scene) + + top_left_global = view.viewport().mapToGlobal(top_left_view) + width = max(1, abs(top_right_view.x() - top_left_view.x())) + height = max(1, abs(bottom_left_view.y() - top_left_view.y())) + top_level_window = view.viewport().window() + if top_level_window is None: + view_window_attr = getattr(view, "window", None) + if callable(view_window_attr): + top_level_window = view_window_attr() + else: + top_level_window = view_window_attr + return QRect(top_left_global, QSize(width, height)), top_level_window + + top_left_global = combo.mapToGlobal(QPoint(0, 0)) + return QRect(top_left_global, combo.size()), combo.window() + + def show_for_combo(self, combo): + self._owner_combo = combo + anchor_rect, top_level_window = self._screen_anchor_rect(combo) + if top_level_window is not None and self.parentWidget() is not top_level_window: + self.setParent(top_level_window, self.windowFlags()) + + self.populate_from_combo(combo) + if self.list_widget.count() == 0: + return + + font_metrics = combo.fontMetrics() + max_text_width = 0 + for index in range(combo.count()): + max_text_width = max(max_text_width, font_metrics.horizontalAdvance(combo.itemText(index))) + + row_height = self.list_widget.sizeHintForRow(0) + if row_height <= 0: + row_height = 34 + + visible_rows = min(max(self.list_widget.count(), 1), 10) + popup_width = max(anchor_rect.width(), min(max_text_width + 56, 560)) + popup_height = (visible_rows * row_height) + 22 + self.resize(popup_width, popup_height) + + target_global = anchor_rect.bottomLeft() + QPoint(0, 4) + screen = QGuiApplication.screenAt(target_global) or QGuiApplication.primaryScreen() + available_geometry = screen.availableGeometry() if screen else None + + x = target_global.x() + y = target_global.y() + + if available_geometry is not None: + if x + self.width() > available_geometry.right() - 12: + x = available_geometry.right() - self.width() - 12 + + if y + self.height() > available_geometry.bottom() - 12: + above_global = combo.mapToGlobal(QPoint(0, -(self.height() + 4))) + y = max(available_geometry.top() + 12, above_global.y()) + + x = max(available_geometry.left() + 12, x) + y = max(available_geometry.top() + 12, y) + + self.move(x, y) + self.show() + self.raise_() + self.activateWindow() + self.list_widget.setFocus() + QTimer.singleShot(0, self._start_close_monitor) + + def _emit_item_selection(self, item): + if item is None: + return + index = item.data(Qt.ItemDataRole.UserRole) + self.item_selected.emit(index, item.text()) + + def _start_close_monitor(self): + if not self.isVisible() or self._close_monitor_active: + return + app = QApplication.instance() + if app is not None: + app.installEventFilter(self) + self._close_monitor_active = True + + def eventFilter(self, watched, event): + if not self.isVisible(): + return False + + if event.type() not in {QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonDblClick}: + return False + + global_pos = event.globalPosition().toPoint() + if self.frameGeometry().contains(global_pos): + return False + + if self._owner_combo is not None: + combo_anchor_rect, _ = self._screen_anchor_rect(self._owner_combo) + if combo_anchor_rect.contains(global_pos): + self.hide() + return True + + self.hide() + return False + + def hideEvent(self, event): + if self._close_monitor_active: + app = QApplication.instance() + if app is not None: + app.removeEventFilter(self) + self._close_monitor_active = False + self.popup_closed.emit() + super().hideEvent(event) + + def focusOutEvent(self, event): + self.hide() + super().focusOutEvent(event) + + +class PopupComboBox(QComboBox): + about_to_show_popup = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._popup = ComboPopup() + self._popup.item_selected.connect(self._apply_popup_selection) + self._popup.popup_closed.connect(self._handle_popup_closed) + self._popup_closing = False + self.destroyed.connect(self._cleanup_popup) + + def apply_popup_style(self, accent_color): + self._popup.apply_style(accent_color) + + def showPopup(self): + self.about_to_show_popup.emit() + if self._popup is not None and self._popup.isVisible(): + self.hidePopup() + return + if not self.isEnabled() or self.count() == 0: + return + self._popup.show_for_combo(self) + + def hidePopup(self): + if self._popup is not None and self._popup.isVisible(): + self._popup_closing = True + self._popup.hide() + self._popup_closing = False + super().hidePopup() + + def _apply_popup_selection(self, index, text): + if 0 <= index < self.count(): + self.setCurrentIndex(index) + else: + self.setCurrentText(text) + self.hidePopup() + self.setFocus() + + def _handle_popup_closed(self): + if not self._popup_closing: + super().hidePopup() + + def _cleanup_popup(self): + if self._popup is not None: + self._popup.hide() + self._popup.deleteLater() + self._popup = None diff --git a/graphite_app/graphite_plugins/graphite_plugin_code_review.py b/graphite_app/graphite_plugins/graphite_plugin_code_review.py deleted file mode 100644 index 99d27fe..0000000 --- a/graphite_app/graphite_plugins/graphite_plugin_code_review.py +++ /dev/null @@ -1,1554 +0,0 @@ -import base64 -from pathlib import Path -from urllib.parse import quote - -import requests -import qtawesome as qta -from PySide6.QtCore import QEvent, QPoint, QPointF, QRect, QRectF, QSize, Qt, QThread, QTimer, Signal -from PySide6.QtGui import QColor, QFont, QGuiApplication, QPainter, QPainterPath, QPen -from PySide6.QtWidgets import ( - QAbstractItemView, - QApplication, - QComboBox, - QFileDialog, - QFrame, - QGraphicsObject, - QGraphicsProxyWidget, - QHBoxLayout, - QLabel, - QLineEdit, - QListWidget, - QListWidgetItem, - QPlainTextEdit, - QPushButton, - QScrollArea, - QTabWidget, - QTextEdit, - QVBoxLayout, - QWidget, -) - -import graphite_config as config -from graphite_canvas_items import HoverAnimationMixin -from graphite_config import get_current_palette, get_semantic_color -from graphite_connections import ConnectionItem -from graphite_plugins.graphite_plugin_context_menu import PluginNodeContextMenu -from graphite_plugins.common.github_client import GitHubRestClient -from graphite_plugins.common.llm_json import call_llm_and_parse_json, extract_json_object - - -class CodeReviewComboPopup(QFrame): - item_selected = Signal(int, str) - popup_closed = Signal() - - def __init__(self, parent=None): - super().__init__( - parent, - Qt.WindowType.Popup - | Qt.WindowType.FramelessWindowHint - | Qt.WindowType.NoDropShadowWindowHint, - ) - self.setObjectName("codeReviewComboPopupFrame") - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - self._owner_combo = None - self._close_monitor_active = False - - outer_layout = QVBoxLayout(self) - outer_layout.setContentsMargins(0, 0, 0, 0) - outer_layout.setSpacing(0) - - self.shell = QFrame() - self.shell.setObjectName("codeReviewComboPopupShell") - outer_layout.addWidget(self.shell) - - shell_layout = QVBoxLayout(self.shell) - shell_layout.setContentsMargins(4, 4, 4, 4) - shell_layout.setSpacing(0) - - self.list_widget = QListWidget() - self.list_widget.setObjectName("codeReviewComboPopupList") - self.list_widget.setFrameShape(QFrame.Shape.NoFrame) - self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.list_widget.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) - self.list_widget.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - self.list_widget.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.list_widget.setSpacing(2) - self.list_widget.setMouseTracking(True) - self.list_widget.itemClicked.connect(self._emit_item_selection) - self.list_widget.itemActivated.connect(self._emit_item_selection) - shell_layout.addWidget(self.list_widget) - - self.apply_style() - - def apply_style(self, accent_color="#2d6fa3"): - self.setStyleSheet( - f""" - QFrame#codeReviewComboPopupFrame {{ - background-color: #1f2327; - border: 1px solid #353b43; - border-radius: 10px; - }} - QFrame#codeReviewComboPopupShell {{ - background: transparent; - border: none; - }} - QListWidget#codeReviewComboPopupList {{ - background: transparent; - color: #ffffff; - border: none; - outline: none; - padding: 2px; - }} - QListWidget#codeReviewComboPopupList::item {{ - background: transparent; - color: #ffffff; - border: none; - border-radius: 6px; - min-height: 26px; - padding: 6px 10px; - }} - QListWidget#codeReviewComboPopupList::item:hover {{ - background-color: #2a3037; - }} - QListWidget#codeReviewComboPopupList::item:selected {{ - background-color: {accent_color}; - color: #ffffff; - }} - QListWidget#codeReviewComboPopupList::item:selected:hover {{ - background-color: {accent_color}; - color: #ffffff; - }} - """ - ) - - def populate_from_combo(self, combo): - current_index = combo.currentIndex() - current_text = combo.currentText() - - self.list_widget.clear() - for index in range(combo.count()): - text = combo.itemText(index) - item = QListWidgetItem(text) - item.setData(Qt.ItemDataRole.UserRole, index) - self.list_widget.addItem(item) - - if current_index < 0 and current_text: - current_index = combo.findText(current_text) - - if 0 <= current_index < self.list_widget.count(): - self.list_widget.setCurrentRow(current_index) - current_item = self.list_widget.item(current_index) - if current_item is not None: - self.list_widget.scrollToItem( - current_item, - QAbstractItemView.ScrollHint.PositionAtCenter, - ) - else: - self.list_widget.clearSelection() - - def _screen_anchor_rect(self, combo): - host_widget = combo.window() - proxy = None - if host_widget is not None and hasattr(host_widget, "graphicsProxyWidget"): - proxy = host_widget.graphicsProxyWidget() - - if proxy is not None and proxy.scene() is not None and proxy.scene().views(): - view = proxy.scene().views()[0] - top_left_host = combo.mapTo(host_widget, QPoint(0, 0)) - top_right_host = combo.mapTo(host_widget, QPoint(combo.width(), 0)) - bottom_left_host = combo.mapTo(host_widget, QPoint(0, combo.height())) - - top_left_scene = proxy.mapToScene(QPointF(top_left_host)) - top_right_scene = proxy.mapToScene(QPointF(top_right_host)) - bottom_left_scene = proxy.mapToScene(QPointF(bottom_left_host)) - - top_left_view = view.mapFromScene(top_left_scene) - top_right_view = view.mapFromScene(top_right_scene) - bottom_left_view = view.mapFromScene(bottom_left_scene) - - top_left_global = view.viewport().mapToGlobal(top_left_view) - width = max(1, abs(top_right_view.x() - top_left_view.x())) - height = max(1, abs(bottom_left_view.y() - top_left_view.y())) - top_level_window = view.viewport().window() - if top_level_window is None: - view_window_attr = getattr(view, "window", None) - if callable(view_window_attr): - top_level_window = view_window_attr() - else: - top_level_window = view_window_attr - return QRect(top_left_global, QSize(width, height)), top_level_window - - top_left_global = combo.mapToGlobal(QPoint(0, 0)) - return QRect(top_left_global, combo.size()), combo.window() - - def show_for_combo(self, combo): - self._owner_combo = combo - anchor_rect, top_level_window = self._screen_anchor_rect(combo) - if top_level_window is not None and self.parentWidget() is not top_level_window: - self.setParent(top_level_window, self.windowFlags()) - - self.populate_from_combo(combo) - if self.list_widget.count() == 0: - return - - font_metrics = combo.fontMetrics() - max_text_width = 0 - for index in range(combo.count()): - max_text_width = max(max_text_width, font_metrics.horizontalAdvance(combo.itemText(index))) - - row_height = self.list_widget.sizeHintForRow(0) - if row_height <= 0: - row_height = 34 - - visible_rows = min(max(self.list_widget.count(), 1), 10) - popup_width = max(anchor_rect.width(), min(max_text_width + 56, 560)) - popup_height = (visible_rows * row_height) + 22 - self.resize(popup_width, popup_height) - - target_global = anchor_rect.bottomLeft() + QPoint(0, 4) - screen = QGuiApplication.screenAt(target_global) or QGuiApplication.primaryScreen() - available_geometry = screen.availableGeometry() if screen else None - - x = target_global.x() - y = target_global.y() - - if available_geometry is not None: - if x + self.width() > available_geometry.right() - 12: - x = available_geometry.right() - self.width() - 12 - - if y + self.height() > available_geometry.bottom() - 12: - above_global = combo.mapToGlobal(QPoint(0, -(self.height() + 4))) - y = max(available_geometry.top() + 12, above_global.y()) - - x = max(available_geometry.left() + 12, x) - y = max(available_geometry.top() + 12, y) - - self.move(x, y) - self.show() - self.raise_() - self.activateWindow() - self.list_widget.setFocus() - QTimer.singleShot(0, self._start_close_monitor) - - def _emit_item_selection(self, item): - if item is None: - return - index = item.data(Qt.ItemDataRole.UserRole) - self.item_selected.emit(index, item.text()) - - def _start_close_monitor(self): - if not self.isVisible() or self._close_monitor_active: - return - app = QApplication.instance() - if app is not None: - app.installEventFilter(self) - self._close_monitor_active = True - - def eventFilter(self, watched, event): - if not self.isVisible(): - return False - - if event.type() not in {QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonDblClick}: - return False - - global_pos = event.globalPosition().toPoint() - if self.frameGeometry().contains(global_pos): - return False - - if self._owner_combo is not None: - combo_anchor_rect, _ = self._screen_anchor_rect(self._owner_combo) - if combo_anchor_rect.contains(global_pos): - self.hide() - return True - - self.hide() - return False - - def hideEvent(self, event): - if self._close_monitor_active: - app = QApplication.instance() - if app is not None: - app.removeEventFilter(self) - self._close_monitor_active = False - self.popup_closed.emit() - super().hideEvent(event) - - def focusOutEvent(self, event): - self.hide() - super().focusOutEvent(event) - - -class CodeReviewPopupComboBox(QComboBox): - about_to_show_popup = Signal() - - def __init__(self, parent=None): - super().__init__(parent) - self._popup = CodeReviewComboPopup() - self._popup.item_selected.connect(self._apply_popup_selection) - self._popup.popup_closed.connect(self._handle_popup_closed) - self._popup_closing = False - self.destroyed.connect(self._cleanup_popup) - - def apply_popup_style(self, accent_color): - self._popup.apply_style(accent_color) - - def showPopup(self): - self.about_to_show_popup.emit() - if self._popup is not None and self._popup.isVisible(): - self.hidePopup() - return - if not self.isEnabled() or self.count() == 0: - return - self._popup.show_for_combo(self) - - def hidePopup(self): - if self._popup is not None and self._popup.isVisible(): - self._popup_closing = True - self._popup.hide() - self._popup_closing = False - super().hidePopup() - - def _apply_popup_selection(self, index, text): - if 0 <= index < self.count(): - self.setCurrentIndex(index) - else: - self.setCurrentText(text) - self.hidePopup() - self.setFocus() - - def _handle_popup_closed(self): - if not self._popup_closing: - super().hidePopup() - - def _cleanup_popup(self): - if self._popup is not None: - self._popup.hide() - self._popup.deleteLater() - self._popup = None - - -CODE_REVIEW_SCROLLBAR_STYLE = """ - QScrollBar:vertical { - background: #1a1d20; - width: 10px; - margin: 0px; - border-radius: 5px; - } - QScrollBar::handle:vertical { - background-color: #555b63; - min-height: 25px; - border-radius: 5px; - } - QScrollBar::handle:vertical:hover { - background-color: #6a727c; - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - height: 0px; - background: none; - } - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { - background: none; - } -""" - - -from graphite_plugins.code_review.scoring import ( - CODE_REVIEW_METRIC_MARKDOWN, - CodeReviewAnalyzer, - _compact_label_text, - _decode_text_bytes, - _is_reviewable_repo_path, - _prepare_numbered_source, - _source_origin_label, -) - - -class CodeReviewWorkerThread(QThread): - finished = Signal(dict) - error = Signal(str) - - def __init__(self, payload): - super().__init__() - self.payload = payload - self.agent = CodeReviewAnalyzer() - self._is_running = True - - def run(self): - try: - if not self._is_running: - return - result = self.agent.get_response(self.payload) - if self._is_running: - self.finished.emit(result) - except Exception as exc: - if self._is_running: - self.error.emit(str(exc)) - finally: - self._is_running = False - - def stop(self): - self._is_running = False - - -class CodeReviewConnectionItem(ConnectionItem): - def paint(self, painter, option, widget=None): - if not (self.start_node and self.end_node): - return - - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Blue"]["color"]) - pen = QPen(node_color, 2, Qt.PenStyle.DashLine) - pen.setCapStyle(Qt.PenCapStyle.RoundCap) - if self.hover: - pen.setWidth(3) - - painter.setPen(pen) - painter.drawPath(self.path) - - if self.is_animating: - for arrow in self.arrows: - self.drawArrow(painter, arrow["pos"], node_color) - - def drawArrow(self, painter, pos, color): - if pos < 0 or pos > 1: - return - point = self.path.pointAtPercent(pos) - angle = self.path.angleAtPercent(pos) - arrow = QPainterPath() - arrow.moveTo(-self.arrow_size, -self.arrow_size / 2) - arrow.lineTo(0, 0) - arrow.lineTo(-self.arrow_size, self.arrow_size / 2) - painter.save() - painter.translate(point) - painter.rotate(-angle) - painter.setBrush(color) - painter.setPen(Qt.PenStyle.NoPen) - painter.drawPath(arrow) - painter.restore() - - -class CodeReviewNode(QGraphicsObject, HoverAnimationMixin): - review_requested = Signal(object) - - NODE_WIDTH = 820 - NODE_HEIGHT = 760 - COLLAPSED_WIDTH = 260 - COLLAPSED_HEIGHT = 40 - CONNECTION_DOT_RADIUS = 5 - CONNECTION_DOT_OFFSET = 0 - - def __init__(self, parent_node, settings_manager=None, parent=None): - super().__init__(parent) - HoverAnimationMixin.__init__(self) - self.parent_node = parent_node - self.settings_manager = settings_manager - self._github_client = GitHubRestClient(settings_manager) - self.children = [] - self.is_user = False - self.conversation_history = [] - self.status = "Idle" - self.quality_score = 0 - self.verdict = "pending" - self.risk_level = "unknown" - self.finding_count = 0 - self.error_count = 0 - self.review_context = "" - self.review_markdown = "" - self.review_data = {} - self.source_state = { - "origin": "", - "label": "", - "repo": "", - "branch": "", - "path": "", - "local_path": "", - "edited": False, - } - self.is_search_match = False - self.hovered = False - self.is_collapsed = False - self.width = self.NODE_WIDTH - self.height = self.NODE_HEIGHT - self.worker_thread = None - self.is_disposed = False - self._suppress_source_state_updates = False - - self.setFlags( - QGraphicsObject.GraphicsItemFlag.ItemIsMovable - | QGraphicsObject.GraphicsItemFlag.ItemIsSelectable - | QGraphicsObject.GraphicsItemFlag.ItemSendsGeometryChanges - ) - self.setAcceptHoverEvents(True) - self.setZValue(0) - self.collapse_button_rect = QRectF() - - self.proxy = QGraphicsProxyWidget(self) - self.proxy_widget = QWidget() - self.proxy_widget.setObjectName("codeReviewMainWidget") - self.proxy_widget.setFixedSize(self.width, self.height) - self.proxy_widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - self.proxy_widget.setStyleSheet( - """ - QWidget#codeReviewMainWidget { - background-color: transparent; - color: #e0e0e0; - } - QWidget#codeReviewMainWidget QLabel { - background-color: transparent; - } - """ - ) - self.proxy.setWidget(self.proxy_widget) - - self._build_ui() - self._apply_verdict_badge("pending") - self._apply_score_badge() - self._apply_count_badges() - self.refresh_github_state() - self._update_source_status() - self.set_status("Idle") - - def _build_ui(self): - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Blue"]["color"]) - badge_rgba = f"{node_color.red()}, {node_color.green()}, {node_color.blue()}" - button_text_color = "#ffffff" - - self.proxy_widget.setStyleSheet(f""" - QWidget#codeReviewMainWidget {{ - background-color: transparent; - color: #e0e0e0; - font-family: 'Segoe UI', sans-serif; - }} - QWidget#codeReviewMainWidget QLabel {{ - background-color: transparent; - }} - QFrame#codeReviewBriefShell, - QFrame#codeReviewSectionCard {{ - background-color: #202327; - border: 1px solid #353b43; - border-radius: 10px; - }} - QLabel#codeReviewSectionHint {{ - color: #95a0ab; - font-size: 11px; - }} - QLabel#codeReviewFieldLabel {{ - color: #c7cdd4; - font-size: 11px; - font-weight: bold; - }} - QLabel#codeReviewMetricBadge {{ - color: {node_color.name()}; - background-color: rgba({badge_rgba}, 0.12); - border: 1px solid rgba({badge_rgba}, 0.26); - border-radius: 10px; - padding: 3px 8px; - font-size: 11px; - font-weight: bold; - }} - QComboBox, QLineEdit, QTextEdit, QPlainTextEdit {{ - background-color: #15181b; - border: 1px solid #2f353d; - color: #d7dce2; - border-radius: 7px; - padding: 8px; - selection-background-color: #264f78; - font-family: 'Segoe UI', sans-serif; - }} - QComboBox:focus, QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {{ - border: 1px solid {node_color.name()}; - }} - QComboBox QAbstractItemView {{ - background-color: #1a1d20; - color: #ffffff; - selection-background-color: #2d6fa3; - border: 1px solid #2b3138; - }} - QTabWidget::pane {{ - border: 1px solid #3a4048; - background: #1d2024; - border-radius: 8px; - }} - QTabBar::tab {{ - background: #25282d; - color: #97a1ab; - padding: 7px 12px; - border: 1px solid #3a4048; - border-bottom: none; - border-top-left-radius: 6px; - border-top-right-radius: 6px; - margin-right: 2px; - font-weight: bold; - }} - QTabBar::tab:selected {{ - background: #1d2024; - color: #ffffff; - border-top: 2px solid {node_color.name()}; - border-bottom: 1px solid #1d2024; - }} - QTabBar::tab:hover:!selected {{ - background: #2d3136; - color: #ffffff; - }} - QScrollArea {{ - background: transparent; - border: none; - }} - QScrollArea > QWidget > QWidget {{ - background: transparent; - }} - """) - - root_layout = QVBoxLayout(self.proxy_widget) - root_layout.setContentsMargins(15, 15, 15, 15) - root_layout.setSpacing(10) - - header_layout = QHBoxLayout() - header_layout.setContentsMargins(4, 0, 4, 0) - header_layout.setSpacing(8) - icon = QLabel() - icon.setPixmap(qta.icon("fa5s.search", color=node_color).pixmap(18, 18)) - header_layout.addWidget(icon) - title = QLabel("Code Review Agent") - title.setStyleSheet(f"font-weight: bold; font-size: 14px; color: {node_color.name()};") - header_layout.addWidget(title) - header_layout.addStretch() - root_layout.addLayout(header_layout) - - line = QFrame() - line.setFrameShape(QFrame.Shape.HLine) - line.setStyleSheet("background-color: #3f3f3f; border: none; height: 1px;") - root_layout.addWidget(line) - - intro_card = QFrame() - intro_card.setObjectName("codeReviewBriefShell") - intro_card.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - intro_layout = QVBoxLayout(intro_card) - intro_layout.setContentsMargins(14, 12, 14, 12) - intro_layout.setSpacing(8) - - controls_row = QHBoxLayout() - controls_row.setContentsMargins(0, 0, 0, 0) - controls_row.setSpacing(10) - - self.run_button = QPushButton("Run Code Review") - self.run_button.setIcon(qta.icon("fa5s.search", color=button_text_color)) - self.run_button.clicked.connect(lambda: self.review_requested.emit(self)) - controls_row.addWidget(self.run_button) - - self.status_label = QLabel("Idle") - self.status_label.setObjectName("codeReviewMetricBadge") - controls_row.addWidget(self.status_label) - controls_row.addStretch() - intro_layout.addLayout(controls_row) - - root_layout.addWidget(intro_card) - - metrics_row = QHBoxLayout() - metrics_row.setContentsMargins(2, 0, 2, 0) - metrics_row.setSpacing(8) - - self.verdict_label = QLabel("Verdict: Pending") - self.verdict_label.setObjectName("codeReviewMetricBadge") - metrics_row.addWidget(self.verdict_label) - - self.score_label = QLabel("Quality: --") - self.score_label.setObjectName("codeReviewMetricBadge") - metrics_row.addWidget(self.score_label) - - self.findings_label = QLabel("Findings: 0") - self.findings_label.setObjectName("codeReviewMetricBadge") - metrics_row.addWidget(self.findings_label) - - self.errors_label = QLabel("Errors: 0") - self.errors_label.setObjectName("codeReviewMetricBadge") - - self.risk_label = QLabel("Risk: --") - self.risk_label.setObjectName("codeReviewMetricBadge") - metrics_row.addWidget(self.risk_label) - metrics_row.addStretch() - - root_layout.addLayout(metrics_row) - - self.workspace_tabs = QTabWidget() - self.workspace_tabs.setDocumentMode(True) - root_layout.addWidget(self.workspace_tabs, stretch=1) - - source_tab = QWidget() - source_layout = QVBoxLayout(source_tab) - source_layout.setContentsMargins(0, 0, 0, 0) - source_layout.setSpacing(8) - - source_hint = QLabel("Use Load to choose the file, Context for review goals, and Source to inspect or paste the exact text that will be reviewed.") - source_hint.setObjectName("codeReviewSectionHint") - source_hint.setWordWrap(True) - source_layout.addWidget(source_hint) - - self.source_tabs = QTabWidget() - self.source_tabs.setDocumentMode(True) - source_layout.addWidget(self.source_tabs, stretch=1) - - load_panel = QWidget() - load_panel_layout = QVBoxLayout(load_panel) - load_panel_layout.setContentsMargins(0, 0, 0, 0) - load_panel_layout.setSpacing(8) - - load_scroll = QScrollArea() - load_scroll.setWidgetResizable(True) - load_scroll.setFrameShape(QFrame.Shape.NoFrame) - load_scroll.setStyleSheet(CODE_REVIEW_SCROLLBAR_STYLE) - load_panel_layout.addWidget(load_scroll) - - load_scroll_content = QWidget() - load_scroll.setWidget(load_scroll_content) - load_scroll_layout = QVBoxLayout(load_scroll_content) - load_scroll_layout.setContentsMargins(0, 0, 0, 0) - load_scroll_layout.setSpacing(8) - - self.source_picker_tabs = QTabWidget() - self.source_picker_tabs.setDocumentMode(True) - load_scroll_layout.addWidget(self.source_picker_tabs) - load_scroll_layout.addStretch() - - github_panel = QWidget() - github_layout = QVBoxLayout(github_panel) - github_layout.setContentsMargins(0, 0, 0, 0) - github_layout.setSpacing(8) - - github_card = QFrame() - github_card.setObjectName("codeReviewSectionCard") - github_card.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - github_card_layout = QVBoxLayout(github_card) - github_card_layout.setContentsMargins(12, 12, 12, 12) - github_card_layout.setSpacing(8) - - github_title = QLabel("GitHub Source") - github_title.setStyleSheet("color: #ffffff; font-weight: bold;") - github_card_layout.addWidget(github_title) - - self.github_state_label = QLabel("") - self.github_state_label.setObjectName("codeReviewSectionHint") - self.github_state_label.setWordWrap(True) - github_card_layout.addWidget(self.github_state_label) - - repo_row = QHBoxLayout() - repo_row.setContentsMargins(0, 0, 0, 0) - repo_row.setSpacing(8) - self.load_repos_button = QPushButton("Load Repo List") - self.load_repos_button.setIcon(qta.icon("fa5s.sync-alt", color=button_text_color)) - self.load_repos_button.clicked.connect(self.load_github_repositories) - repo_row.addWidget(self.load_repos_button) - - self.repo_combo = CodeReviewPopupComboBox() - self.repo_combo.setEditable(False) - self.repo_combo.setMinimumWidth(200) - self.repo_combo.apply_popup_style("#2d6fa3") - self.repo_combo.about_to_show_popup.connect(self._ensure_github_repositories_loaded) - self.repo_combo.currentTextChanged.connect(self._on_repo_selected) - repo_row.addWidget(self.repo_combo, stretch=1) - github_card_layout.addLayout(repo_row) - - repo_label = QLabel("Repository") - repo_label.setObjectName("codeReviewFieldLabel") - github_card_layout.addWidget(repo_label) - self.repo_input = QLineEdit() - self.repo_input.setPlaceholderText("owner/repo") - github_card_layout.addWidget(self.repo_input) - - branch_row = QHBoxLayout() - branch_row.setContentsMargins(0, 0, 0, 0) - branch_row.setSpacing(8) - self.branch_input = QLineEdit() - self.branch_input.setPlaceholderText("Branch (leave blank for repo default)") - branch_row.addWidget(self.branch_input) - - self.refresh_files_button = QPushButton("Load File List") - self.refresh_files_button.setIcon(qta.icon("fa5s.folder-open", color=button_text_color)) - self.refresh_files_button.clicked.connect(self.load_github_file_list) - branch_row.addWidget(self.refresh_files_button) - github_card_layout.addLayout(branch_row) - - file_label = QLabel("Repository File") - file_label.setObjectName("codeReviewFieldLabel") - github_card_layout.addWidget(file_label) - file_row = QHBoxLayout() - file_row.setContentsMargins(0, 0, 0, 0) - file_row.setSpacing(8) - self.file_combo = CodeReviewPopupComboBox() - self.file_combo.setEditable(True) - self.file_combo.setMaxVisibleItems(20) - self.file_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - self.file_combo.setMinimumWidth(220) - self.file_combo.apply_popup_style("#2d6fa3") - self.file_combo.lineEdit().setPlaceholderText("Repository file path") - file_row.addWidget(self.file_combo, stretch=1) - - self.load_github_file_button = QPushButton("Load File") - self.load_github_file_button.setIcon(qta.icon("fa5s.download", color=button_text_color)) - self.load_github_file_button.clicked.connect(self.load_selected_github_file) - file_row.addWidget(self.load_github_file_button) - github_card_layout.addLayout(file_row) - github_layout.addWidget(github_card) - - local_panel = QWidget() - local_panel_layout = QVBoxLayout(local_panel) - local_panel_layout.setContentsMargins(0, 0, 0, 0) - local_panel_layout.setSpacing(8) - - local_card = QFrame() - local_card.setObjectName("codeReviewSectionCard") - local_card.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - local_layout = QVBoxLayout(local_card) - local_layout.setContentsMargins(12, 12, 12, 12) - local_layout.setSpacing(8) - - local_title = QLabel("Local Source") - local_title.setStyleSheet("color: #ffffff; font-weight: bold;") - local_layout.addWidget(local_title) - - local_hint = QLabel("Browse to a file on disk, or paste code directly in the Source panel if you want a quick manual review.") - local_hint.setObjectName("codeReviewSectionHint") - local_hint.setWordWrap(True) - local_layout.addWidget(local_hint) - - self.local_path_input = QLineEdit() - self.local_path_input.setPlaceholderText("Local file path") - local_layout.addWidget(self.local_path_input) - - local_row = QHBoxLayout() - local_row.setContentsMargins(0, 0, 0, 0) - local_row.setSpacing(8) - - self.browse_button = QPushButton("Browse") - self.browse_button.setIcon(qta.icon("fa5s.folder-open", color=button_text_color)) - self.browse_button.clicked.connect(self.browse_local_file) - local_row.addWidget(self.browse_button) - - self.load_local_button = QPushButton("Load Local File") - self.load_local_button.setIcon(qta.icon("fa5s.file-import", color=button_text_color)) - self.load_local_button.clicked.connect(self.load_local_file) - local_row.addWidget(self.load_local_button) - local_row.addStretch() - local_layout.addLayout(local_row) - local_panel_layout.addWidget(local_card) - - self.source_picker_tabs.addTab(github_panel, qta.icon("fa5s.code-branch", color="#cccccc"), "GitHub") - self.source_picker_tabs.addTab(local_panel, qta.icon("fa5s.folder-open", color="#cccccc"), "Local") - - context_panel = QWidget() - context_layout = QVBoxLayout(context_panel) - context_layout.setContentsMargins(0, 0, 0, 0) - context_layout.setSpacing(8) - - context_hint = QLabel("Optional review context helps the agent judge the file against the right intent, risks, and constraints.") - context_hint.setObjectName("codeReviewSectionHint") - context_hint.setWordWrap(True) - context_layout.addWidget(context_hint) - - context_label = QLabel("Review Context") - context_label.setObjectName("codeReviewFieldLabel") - context_layout.addWidget(context_label) - - self.context_input = QTextEdit() - self.context_input.setPlaceholderText("What should this file do, what matters most, and what would make you uneasy about shipping it?") - self.context_input.setFixedHeight(140) - self.context_input.setStyleSheet("QTextEdit { font-size: 12px; }" + CODE_REVIEW_SCROLLBAR_STYLE) - self.context_input.textChanged.connect(self._on_context_changed) - context_layout.addWidget(self.context_input) - context_layout.addStretch() - - source_panel = QWidget() - source_panel_layout = QVBoxLayout(source_panel) - source_panel_layout.setContentsMargins(0, 0, 0, 0) - source_panel_layout.setSpacing(8) - - source_header = QHBoxLayout() - source_header.setContentsMargins(0, 0, 0, 0) - source_header.setSpacing(8) - - source_title = QLabel("Source Under Review") - source_title.setStyleSheet("color: #ffffff; font-weight: bold;") - source_header.addWidget(source_title) - source_header.addStretch() - - self.source_status_label = QLabel("") - self.source_status_label.setObjectName("codeReviewSectionHint") - source_header.addWidget(self.source_status_label) - - self.clear_source_button = QPushButton("Clear") - self.clear_source_button.setIcon(qta.icon("fa5s.eraser", color=button_text_color)) - self.clear_source_button.clicked.connect(self.clear_source) - source_header.addWidget(self.clear_source_button) - source_panel_layout.addLayout(source_header) - - self.source_editor = QPlainTextEdit() - self.source_editor.setPlaceholderText("Paste code here, or load a file from GitHub or your local machine.") - self.source_editor.setFont(QFont("Consolas", 10)) - self.source_editor.textChanged.connect(self._on_source_changed) - self.source_editor.setStyleSheet("QPlainTextEdit { font-family: 'Consolas'; }" + CODE_REVIEW_SCROLLBAR_STYLE) - source_panel_layout.addWidget(self.source_editor, stretch=1) - - self.source_tabs.addTab(load_panel, qta.icon("fa5s.file-import", color="#cccccc"), "Load") - self.source_tabs.addTab(context_panel, qta.icon("fa5s.comment", color="#cccccc"), "Context") - self.source_tabs.addTab(source_panel, qta.icon("fa5s.file-code", color="#cccccc"), "Source") - - self.workspace_tabs.addTab(source_tab, qta.icon("fa5s.file-code", color="#cccccc"), "Source") - - self.overview_display = self._create_markdown_display("Run the review to generate the executive overview.") - self.findings_display = self._create_markdown_display("Review findings will appear here after the analysis runs.") - self.errors_display = self._create_markdown_display("High-confidence errors will appear here after the analysis runs.") - self.quality_display = self._create_markdown_display("The weighted quality report will appear here after the analysis runs.") - self.metric_display = self._create_markdown_display("") - self.metric_display.setMarkdown(CODE_REVIEW_METRIC_MARKDOWN) - - self.workspace_tabs.addTab(self.overview_display, qta.icon("fa5s.clipboard", color="#cccccc"), "Overview") - self.workspace_tabs.addTab(self.findings_display, qta.icon("fa5s.list-ul", color="#cccccc"), "Findings (0)") - self.workspace_tabs.addTab(self.errors_display, qta.icon("fa5s.bug", color="#cccccc"), "Errors (0)") - self.workspace_tabs.addTab(self.quality_display, qta.icon("fa5s.chart-line", color="#cccccc"), "Quality") - self.workspace_tabs.addTab(self.metric_display, qta.icon("fa5s.ruler-combined", color="#cccccc"), "Rubric") - - button_style = f""" - QPushButton {{ - background-color: {node_color.name()}; - color: {button_text_color}; - border: none; - border-radius: 8px; - padding: 9px 14px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: {node_color.lighter(108).name()}; - }} - QPushButton:disabled {{ - background-color: #555555; - color: #cccccc; - }} - """ - for button in ( - self.run_button, - self.load_repos_button, - self.refresh_files_button, - self.load_github_file_button, - self.browse_button, - self.load_local_button, - self.clear_source_button, - ): - button.setStyleSheet(button_style) - - def _create_markdown_display(self, placeholder_text): - display = QTextEdit() - display.setReadOnly(True) - display.setPlaceholderText(placeholder_text) - display.document().setDefaultStyleSheet( - """ - h1, h2, h3 { color: #ffffff; } - p, li { color: #d6d6d6; font-family: 'Segoe UI', sans-serif; font-size: 12px; } - code { background-color: #31353b; padding: 2px 4px; border-radius: 4px; color: #ffe07d; } - """ - ) - display.setStyleSheet( - "QTextEdit { font-size: 12px; background-color: #121417; border: 1px solid #2b3138; border-radius: 10px; padding: 6px; }" - + CODE_REVIEW_SCROLLBAR_STYLE - ) - return display - - def _get_github_token(self): - # Delegates to the shared GitHubRestClient (doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md - # section 1.6/4.2) - kept as a thin wrapper so every existing call site in this - # file (load_github_repositories, _resolve_repo_and_branch, etc.) is unchanged. - return self._github_client.get_token() - - def _github_headers(self): - return self._github_client.build_headers() - - def _github_request(self, url, params=None, **kwargs): - return self._github_client.request(url, params, **kwargs) - - def refresh_github_state(self): - token = self._get_github_token() - if token: - detail_text = "GitHub token detected. Private repositories and authenticated repo listing are enabled." - self.github_state_label.setText("GitHub token ready. Private repos enabled.") - else: - detail_text = "No GitHub token saved. Repo list loading is disabled, but public repositories still work when you enter owner/repo manually." - self.github_state_label.setText("No GitHub token saved. Public repos still work.") - self.github_state_label.setToolTip(detail_text) - - def _on_repo_selected(self, repo_name): - if repo_name: - self.repo_input.setText(repo_name) - self.file_combo.clear() - - def _ensure_github_repositories_loaded(self): - if self.repo_combo.count() == 0: - self.load_github_repositories() - - def _on_context_changed(self): - self.review_context = self.context_input.toPlainText() - - def seed_prompt(self, text): - """Protocol method used by graphite_window_actions.instantiate_seeded_plugin.""" - self.context_input.setPlainText(text) - self._on_context_changed() - - def _on_source_changed(self): - if self._suppress_source_state_updates: - return - source_text = self.source_editor.toPlainText() - if source_text.strip(): - if not self.source_state.get("origin"): - self.source_state["origin"] = "manual" - self.source_state["label"] = "Manual / pasted source" - else: - self.source_state["edited"] = True - elif not self._suppress_source_state_updates: - self.source_state = { - "origin": "", - "label": "", - "repo": "", - "branch": "", - "path": "", - "local_path": "", - "edited": False, - } - self._update_source_status() - - def _update_source_status(self): - source_text = self.source_editor.toPlainText() - line_count = len(source_text.splitlines()) if source_text else 0 - char_count = len(source_text) - origin_label = _source_origin_label(self.source_state) - if char_count == 0: - summary_text = "No source loaded yet." - detail_text = summary_text - else: - detail_text = f"{origin_label} | {line_count} lines, {char_count} chars" - summary_text = f"{origin_label} | {line_count} lines" - self.source_status_label.setText(_compact_label_text(summary_text, limit=44)) - self.source_status_label.setToolTip(detail_text) - - def browse_local_file(self): - path, _ = QFileDialog.getOpenFileName(None, "Choose a file to review") - if path: - self.local_path_input.setText(path) - - def load_local_file(self): - path_text = self.local_path_input.text().strip() - if not path_text: - self.set_status("Error: Select a local file first.") - return - - path = Path(path_text) - if not path.exists() or not path.is_file(): - self.set_status("Error: The selected local file could not be found.") - return - - try: - raw_bytes = path.read_bytes() - source_text = _decode_text_bytes(raw_bytes) - except Exception as exc: - self.set_status(f"Error: {exc}") - return - - self._set_source_text( - source_text, - { - "origin": "local", - "label": path.name, - "repo": "", - "branch": "", - "path": "", - "local_path": str(path), - "edited": False, - }, - ) - self.set_status("Loaded local file.") - - def clear_source(self): - self._set_source_text("", { - "origin": "", - "label": "", - "repo": "", - "branch": "", - "path": "", - "local_path": "", - "edited": False, - }) - self.local_path_input.clear() - self.file_combo.clear() - self.set_status("Source cleared.") - - def load_github_repositories(self): - self.refresh_github_state() - if not self._get_github_token(): - self.set_status("Error: Save a GitHub token in Settings > Integrations to load your private repo list.") - return - - try: - repos = [] - page = 1 - while True: - page_payload = self._github_request( - "https://api.github.com/user/repos", - params={ - "per_page": 100, - "page": page, - "sort": "updated", - "visibility": "all", - "affiliation": "owner,collaborator,organization_member", - }, - ) - if not page_payload: - break - repos.extend(item.get("full_name", "") for item in page_payload if item.get("full_name")) - if len(page_payload) < 100 or page >= 5: - break - page += 1 - - self.repo_combo.clear() - self.repo_combo.addItems(sorted(set(repos), key=str.lower)) - if self.repo_combo.count() == 0: - self.set_status("GitHub returned no accessible repositories for this token.") - else: - self.set_status(f"Loaded {self.repo_combo.count()} repositories from GitHub.") - except Exception as exc: - self.set_status(f"Error: {exc}") - - def _resolve_repo_and_branch(self): - repo_name = self.repo_input.text().strip() or self.repo_combo.currentText().strip() - if not repo_name or "/" not in repo_name: - raise RuntimeError("Enter a repository as `owner/repo`.") - - repo_payload = self._github_request(f"https://api.github.com/repos/{repo_name}") - default_branch = repo_payload.get("default_branch", "") - branch_name = self.branch_input.text().strip() or default_branch - if not branch_name: - raise RuntimeError("GitHub did not provide a default branch for this repository.") - - self.repo_input.setText(repo_name) - self.branch_input.setText(branch_name) - return repo_name, branch_name - - def load_github_file_list(self): - self.refresh_github_state() - try: - repo_name, branch_name = self._resolve_repo_and_branch() - tree_payload = self._github_request( - f"https://api.github.com/repos/{repo_name}/git/trees/{quote(branch_name, safe='')}", - params={"recursive": 1}, - ) - tree_items = tree_payload.get("tree", []) - paths = [ - item.get("path", "") - for item in tree_items - if item.get("type") == "blob" and item.get("path") and _is_reviewable_repo_path(item.get("path", "")) - ] - limited_paths = sorted(paths)[:5000] - current_text = self.file_combo.currentText().strip() - self.file_combo.clear() - self.file_combo.addItems(limited_paths) - if current_text: - self.file_combo.setCurrentText(current_text) - - status_text = f"Loaded {len(limited_paths)} file paths from {repo_name}@{branch_name}." - if tree_payload.get("truncated"): - status_text += " GitHub reported the tree as truncated." - self.set_status(status_text) - except Exception as exc: - self.set_status(f"Error: {exc}") - - def load_selected_github_file(self): - self.refresh_github_state() - try: - repo_name, branch_name = self._resolve_repo_and_branch() - file_path = self.file_combo.currentText().strip() - if not file_path: - raise RuntimeError("Choose or type a repository file path first.") - - content_payload = self._github_request( - f"https://api.github.com/repos/{repo_name}/contents/{quote(file_path, safe='/')}", - params={"ref": branch_name}, - ) - if isinstance(content_payload, list): - raise RuntimeError("The selected path resolves to a directory, not a file.") - - if content_payload.get("encoding") == "base64" and content_payload.get("content"): - source_text = _decode_text_bytes(base64.b64decode(content_payload["content"])) - elif content_payload.get("download_url"): - download_response = requests.get(content_payload["download_url"], timeout=25) - download_response.raise_for_status() - source_text = download_response.text - else: - raise RuntimeError("GitHub did not return file contents for this path.") - - self._set_source_text( - source_text, - { - "origin": "github", - "label": Path(file_path).name or file_path, - "repo": repo_name, - "branch": branch_name, - "path": file_path, - "local_path": "", - "edited": False, - }, - ) - self.set_status("Loaded GitHub file.") - except requests.HTTPError as exc: - self.set_status(f"Error: {exc}") - except Exception as exc: - self.set_status(f"Error: {exc}") - - def _set_source_text(self, source_text, source_state): - self._suppress_source_state_updates = True - self.source_editor.setPlainText(source_text) - self.source_state = dict(source_state) - self._suppress_source_state_updates = False - - self.repo_input.setText(self.source_state.get("repo", "")) - self.branch_input.setText(self.source_state.get("branch", "")) - self.local_path_input.setText(self.source_state.get("local_path", "")) - if self.source_state.get("path"): - self.file_combo.setCurrentText(self.source_state["path"]) - else: - self.file_combo.setCurrentText("") - self._update_source_status() - if hasattr(self, "workspace_tabs"): - self.workspace_tabs.setCurrentIndex(0) - if hasattr(self, "source_tabs"): - self.source_tabs.setCurrentIndex(2 if source_text.strip() else 0) - - def get_review_context(self): - return self.context_input.toPlainText() - - def build_review_payload(self): - source_text = self.source_editor.toPlainText() - source_for_model, truncated, total_lines, visible_lines = _prepare_numbered_source(source_text) - return { - "source_text": source_text, - "source_for_model": source_for_model, - "source_truncated": truncated, - "source_state": dict(self.source_state), - "review_context": self.get_review_context(), - "total_lines": total_lines, - "visible_lines": visible_lines, - } - - def _apply_verdict_badge(self, verdict): - verdict = (verdict or "pending").lower() - if verdict == "strong": - color = get_semantic_color("status_success") - text = "Verdict: Strong" - elif verdict == "needs_revision": - color = get_semantic_color("status_warning") - text = "Verdict: Needs Revision" - elif verdict == "not_ready": - color = get_semantic_color("status_error") - text = "Verdict: Not Ready" - else: - color = QColor("#9aa3ad") - text = "Verdict: Pending" - - self.verdict_label.setText(text) - self.verdict_label.setStyleSheet( - f"color: {color.name()}; background-color: rgba({color.red()}, {color.green()}, {color.blue()}, 0.1); " - f"border: 1px solid rgba({color.red()}, {color.green()}, {color.blue()}, 0.24); border-radius: 10px; " - "padding: 3px 8px; font-size: 11px; font-weight: bold;" - ) - - def _apply_score_badge(self): - if self.quality_score >= 78: - color = get_semantic_color("status_success") - elif self.quality_score >= 60: - color = get_semantic_color("status_warning") - else: - color = get_semantic_color("status_error") - - text = f"Quality: {self.quality_score}/100" if self.quality_score else "Quality: --" - self.score_label.setText(text) - self.score_label.setStyleSheet( - f"color: {color.name()}; background-color: rgba({color.red()}, {color.green()}, {color.blue()}, 0.1); " - f"border: 1px solid rgba({color.red()}, {color.green()}, {color.blue()}, 0.24); border-radius: 10px; " - "padding: 3px 8px; font-size: 11px; font-weight: bold;" - ) - - def _apply_count_badges(self): - palette = get_current_palette() - info_color = QColor(palette.FRAME_COLORS["Blue"]["color"]) - error_color = get_semantic_color("status_error") - risk_color = { - "low": get_semantic_color("status_success"), - "medium": get_semantic_color("status_warning"), - "high": get_semantic_color("status_error"), - }.get(self.risk_level, QColor("#9aa3ad")) - - for label, value, color in ( - (self.findings_label, f"Findings: {self.finding_count}", info_color), - (self.errors_label, f"Errors: {self.error_count}", error_color if self.error_count else info_color), - (self.risk_label, f"Risk: {self.risk_level.title() if self.risk_level else '--'}", risk_color), - ): - label.setText(value) - label.setStyleSheet( - f"color: {color.name()}; background-color: rgba({color.red()}, {color.green()}, {color.blue()}, 0.1); " - f"border: 1px solid rgba({color.red()}, {color.green()}, {color.blue()}, 0.24); border-radius: 10px; " - "padding: 3px 8px; font-size: 11px; font-weight: bold;" - ) - - def set_running_state(self, is_running): - if self.is_disposed: - return - - for widget in ( - self.run_button, - self.load_repos_button, - self.refresh_files_button, - self.load_github_file_button, - self.browse_button, - self.load_local_button, - self.clear_source_button, - ): - widget.setEnabled(not is_running) - - self.source_editor.setReadOnly(is_running) - self.context_input.setReadOnly(is_running) - self.run_button.setText("Reviewing..." if is_running else "Run Code Review") - - if is_running: - self.set_status("Reviewing source...") - elif self.status.startswith("Error"): - return - elif self.review_markdown.strip(): - self.set_status("Review complete.") - else: - self.set_status("Idle") - - def set_status(self, status_text): - if self.is_disposed: - return - - self.status = status_text - self.status_label.setText(_compact_label_text(status_text, limit=26)) - self.status_label.setToolTip(status_text) - if "Reviewing" in status_text: - color = get_semantic_color("status_info") - elif "Error" in status_text: - color = get_semantic_color("status_error") - elif "complete" in status_text.lower(): - color = get_semantic_color("status_success") - else: - color = QColor("#9aa3ad") - - self.status_label.setStyleSheet( - f"color: {color.name()}; background-color: rgba({color.red()}, {color.green()}, {color.blue()}, 0.1); " - f"border: 1px solid rgba({color.red()}, {color.green()}, {color.blue()}, 0.24); border-radius: 10px; " - "padding: 3px 8px; font-size: 11px; font-weight: bold;" - ) - - def set_review(self, review): - if self.is_disposed: - return - - self.review_data = dict(review or {}) - self.review_markdown = self.review_data.get("review_markdown", "") - self.quality_score = int(self.review_data.get("quality_score", 0) or 0) - self.verdict = self.review_data.get("verdict", "pending") - self.risk_level = self.review_data.get("risk_level", "unknown") - self.finding_count = int(self.review_data.get("finding_count", 0) or 0) - self.error_count = int(self.review_data.get("error_count", 0) or 0) - - self.overview_display.setMarkdown(self.review_data.get("overview_markdown", "")) - self.findings_display.setMarkdown(self.review_data.get("findings_markdown", "")) - self.errors_display.setMarkdown(self.review_data.get("errors_markdown", "")) - self.quality_display.setMarkdown(self.review_data.get("quality_report_markdown", "")) - self.metric_display.setMarkdown(self.review_data.get("metric_markdown", CODE_REVIEW_METRIC_MARKDOWN)) - - self.workspace_tabs.setTabText(2, f"Findings ({self.finding_count})") - self.workspace_tabs.setTabText(3, f"Errors ({self.error_count})") - - self._apply_verdict_badge(self.verdict) - self._apply_score_badge() - self._apply_count_badges() - self.set_status("Review complete.") - - def set_error(self, error_message): - if self.is_disposed: - return - - self.review_data = {} - self.review_markdown = f"## Error\n\n{error_message}" - self.quality_score = 0 - self.verdict = "not_ready" - self.risk_level = "high" - self.finding_count = 0 - self.error_count = 0 - - self.overview_display.setMarkdown(self.review_markdown) - self.findings_display.setMarkdown("## Review Findings\n\nNo findings available because the review did not complete.") - self.errors_display.setMarkdown("## Errors Found\n\nThe review itself failed before issue extraction could complete.") - self.quality_display.setMarkdown("## Code Quality Report\n\nA deterministic quality report could not be generated because the review failed.") - self.metric_display.setMarkdown(CODE_REVIEW_METRIC_MARKDOWN) - self.workspace_tabs.setTabText(2, "Findings (0)") - self.workspace_tabs.setTabText(3, "Errors (0)") - - self._apply_verdict_badge("not_ready") - self._apply_score_badge() - self._apply_count_badges() - self.set_status(f"Error: {error_message}") - - def set_collapsed(self, collapsed): - if self.is_collapsed == collapsed: - return - - self.prepareGeometryChange() - self.is_collapsed = collapsed - if collapsed: - self.width = self.COLLAPSED_WIDTH - self.height = self.COLLAPSED_HEIGHT - self.proxy.setVisible(False) - else: - self.width = self.NODE_WIDTH - self.height = self.NODE_HEIGHT - self.proxy.setVisible(True) - self.proxy_widget.setFixedSize(self.width, self.height) - if self.scene(): - self.scene().update_connections() - self.scene().nodeMoved(self) - self.update() - - def toggle_collapse(self): - self.set_collapsed(not self.is_collapsed) - - def dispose(self): - if self.is_disposed: - return - - self.is_disposed = True - - worker_thread = self.worker_thread - self.worker_thread = None - if worker_thread: - try: - worker_thread.stop() - except Exception: - pass - for signal in (worker_thread.finished, worker_thread.error): - try: - signal.disconnect() - except (TypeError, RuntimeError): - pass - try: - if worker_thread.isRunning(): - worker_thread.finished.connect(worker_thread.deleteLater) - else: - worker_thread.deleteLater() - except RuntimeError: - pass - - proxy = getattr(self, "proxy", None) - if proxy is not None: - try: - embedded_widget = proxy.widget() - except RuntimeError: - embedded_widget = None - try: - proxy.setWidget(None) - except RuntimeError: - pass - if embedded_widget is not None: - try: - embedded_widget.hide() - embedded_widget.deleteLater() - except RuntimeError: - pass - - def boundingRect(self): - padding = self.CONNECTION_DOT_OFFSET + self.CONNECTION_DOT_RADIUS - return QRectF(-padding, 0, self.width + 2 * padding, self.height) - - def paint(self, painter, option, widget=None): - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Blue"]["color"]) - - path = QPainterPath() - path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2d2d2d")) - - pen = QPen(node_color, 1.5) - if self.isSelected(): - pen = QPen(palette.SELECTION, 2) - elif self.is_search_match: - pen = QPen(get_semantic_color("search_highlight"), 2) - elif self.hovered: - pen = QPen(QColor("#ffffff"), 2) - - painter.setPen(pen) - painter.drawPath(path) - - dot_color = node_color if not (self.isSelected() or self.hovered) else pen.color().lighter(110) - painter.setBrush(dot_color) - painter.setPen(Qt.PenStyle.NoPen) - - dot_rect_left = QRectF(-self.CONNECTION_DOT_RADIUS, (self.height / 2) - self.CONNECTION_DOT_RADIUS, self.CONNECTION_DOT_RADIUS * 2, self.CONNECTION_DOT_RADIUS * 2) - painter.drawPie(dot_rect_left, 90 * 16, -180 * 16) - - dot_rect_right = QRectF(self.width - self.CONNECTION_DOT_RADIUS, (self.height / 2) - self.CONNECTION_DOT_RADIUS, self.CONNECTION_DOT_RADIUS * 2, self.CONNECTION_DOT_RADIUS * 2) - painter.drawPie(dot_rect_right, 90 * 16, 180 * 16) - - if self.is_collapsed: - painter.setPen(QColor("#ffffff")) - painter.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold)) - painter.drawText(QRectF(42, 0, self.width - 84, self.height), Qt.AlignmentFlag.AlignVCenter, "Code Review Agent") - qta.icon("fa5s.search", color=node_color.name()).paint(painter, QRect(12, 10, 20, 20)) - self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - qta.icon("fa5s.expand-arrows-alt", color="#ffffff" if self.hovered else "#888888").paint(painter, QRect(int(self.width - 30), 10, 20, 20)) - else: - if self.hovered: - self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - painter.setBrush(QColor(255, 255, 255, 30)) - painter.setPen(QColor(255, 255, 255, 150)) - painter.drawRoundedRect(self.collapse_button_rect.adjusted(6, 6, -6, -6), 4, 4) - painter.setPen(QPen(QColor("#ffffff"), 2)) - center = self.collapse_button_rect.center() - painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) - else: - self.collapse_button_rect = QRectF() - - def mousePressEvent(self, event): - if self.collapse_button_rect.contains(event.pos()): - self.toggle_collapse() - event.accept() - return - - if event.button() == Qt.MouseButton.LeftButton and self.scene(): - self.scene().is_dragging_item = True - if hasattr(self.scene(), "window"): - self.scene().window.setCurrentNode(self) - super().mousePressEvent(event) - - def contextMenuEvent(self, event): - menu = PluginNodeContextMenu(self) - menu.exec(event.screenPos()) - - def mouseReleaseEvent(self, event): - if self.scene(): - self.scene().is_dragging_item = False - self.scene()._clear_smart_guides() - super().mouseReleaseEvent(event) - - def itemChange(self, change, value): - if change == QGraphicsObject.GraphicsItemChange.ItemPositionChange and self.scene() and self.scene().is_dragging_item: - return self.scene().snap_position(self, value) - if change == QGraphicsObject.GraphicsItemChange.ItemPositionHasChanged and self.scene(): - self.scene().nodeMoved(self) - return super().itemChange(change, value) - - def hoverEnterEvent(self, event): - self._handle_hover_enter(event) - super().hoverEnterEvent(event) - - def hoverLeaveEvent(self, event): - self._handle_hover_leave(event) - super().hoverLeaveEvent(event) diff --git a/graphite_app/graphite_plugins/graphite_plugin_gitlink.py b/graphite_app/graphite_plugins/graphite_plugin_gitlink.py index b17ee40..2178f4f 100644 --- a/graphite_app/graphite_plugins/graphite_plugin_gitlink.py +++ b/graphite_app/graphite_plugins/graphite_plugin_gitlink.py @@ -50,7 +50,7 @@ _truncate_for_context, _xml_file_block, ) -from graphite_plugins.graphite_plugin_code_review import CodeReviewPopupComboBox +from graphite_plugins.common.combo import PopupComboBox GITLINK_SCROLLBAR_STYLE = """ @@ -472,7 +472,7 @@ def _build_ui(self): self.load_repos_button.clicked.connect(self.load_github_repositories) repo_picker_row.addWidget(self.load_repos_button) - self.repo_combo = CodeReviewPopupComboBox() + self.repo_combo = PopupComboBox() self.repo_combo.setEditable(False) self.repo_combo.setMinimumWidth(220) self.repo_combo.apply_popup_style(node_color.name()) diff --git a/graphite_app/graphite_plugins/graphite_plugin_graph_diff.py b/graphite_app/graphite_plugins/graphite_plugin_graph_diff.py deleted file mode 100644 index 62228f9..0000000 --- a/graphite_app/graphite_plugins/graphite_plugin_graph_diff.py +++ /dev/null @@ -1,900 +0,0 @@ -import difflib -import json -import re - -from PySide6.QtCore import QRect, QRectF, Qt, QThread, Signal -from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen -from PySide6.QtWidgets import ( - QFrame, - QGraphicsObject, - QGraphicsProxyWidget, - QHBoxLayout, - QLabel, - QPushButton, - QTextEdit, - QVBoxLayout, - QWidget, -) -import qtawesome as qta - -import api_provider -import graphite_config as config -from graphite_canvas_items import HoverAnimationMixin -from graphite_connections import ConnectionItem -from graphite_config import get_current_palette, get_semantic_color -from graphite_plugins.graphite_plugin_context_menu import PluginNodeContextMenu - - -GRAPH_DIFF_SCROLLBAR_STYLE = """ - QScrollBar:vertical { - background: #252526; - width: 10px; - margin: 0px; - border-radius: 5px; - } - QScrollBar::handle:vertical { - background-color: #555555; - min-height: 25px; - border-radius: 5px; - } - QScrollBar::handle:vertical:hover { - background-color: #6a6a6a; - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - height: 0px; - background: none; - } - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { - background: none; - } - QScrollBar:horizontal { - background: #252526; - height: 10px; - margin: 0px; - border-radius: 5px; - } - QScrollBar::handle:horizontal { - background-color: #555555; - min-width: 25px; - border-radius: 5px; - } - QScrollBar::handle:horizontal:hover { - background-color: #6a6a6a; - } - QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { - width: 0px; - background: none; - } - QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { - background: none; - } -""" - - -def _flatten_content(content): - if isinstance(content, str): - return content - if isinstance(content, list): - parts = [] - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - parts.append(item.get("text", "")) - return "\n".join(part for part in parts if part) - return str(content) - - -def _clean_text(text, limit=2500): - text = re.sub(r"\n{3,}", "\n\n", text or "").strip() - if len(text) > limit: - return text[: limit - 3].rstrip() + "..." - return text - - -_NON_PLUGIN_NODE_LABELS = { - # Core node types that aren't registered plugins (see PLUGIN_REGISTRY in - # graphite_plugin_portal.py) but can still appear in a branch transcript. - "ChatNode": "Chat Node", -} - - -def _node_label(node): - class_name = node.__class__.__name__ - if class_name in _NON_PLUGIN_NODE_LABELS: - return _NON_PLUGIN_NODE_LABELS[class_name] - - # Deferred import: graphite_plugin_portal imports this module (to register the - # Branch Lens plugin), so importing it back at module load time would be circular. - # By the time _node_label() actually runs, both modules are already fully loaded. - from graphite_plugins.graphite_plugin_portal import get_display_name_for_node - - return get_display_name_for_node(node) - - -def _extract_node_text(node): - parts = [] - - if hasattr(node, "text") and isinstance(getattr(node, "text"), str): - parts.append(node.text) - - if hasattr(node, "conversation_history"): - for message in getattr(node, "conversation_history", [])[-6:]: - role = message.get("role", "unknown").title() - content = _flatten_content(message.get("content", "")) - if content.strip(): - parts.append(f"{role}: {content.strip()}") - - for attr in ("prompt", "thinking_text", "thought_process", "blueprint_markdown", "review_markdown", "html_content"): - value = getattr(node, attr, "") - if isinstance(value, str) and value.strip(): - parts.append(value) - - for getter_name, prefix in (("get_goal", "Goal"), ("get_constraints", "Constraints"), ("get_criteria", "Acceptance Criteria")): - getter = getattr(node, getter_name, None) - if callable(getter): - try: - value = getter().strip() - if value: - parts.append(f"{prefix}: {value}") - except Exception: - pass - - if hasattr(node, "query_input"): - query = node.query_input.text().strip() - if query: - parts.append(f"Query: {query}") - - if hasattr(node, "summary_text") and isinstance(getattr(node, "summary_text"), str): - if node.summary_text.strip(): - parts.append(node.summary_text) - - if hasattr(node, "prompt_input"): - try: - prompt_text = node.prompt_input.toPlainText().strip() - if prompt_text: - parts.append(prompt_text) - except Exception: - pass - - if hasattr(node, "get_requirements"): - try: - requirements_text = node.get_requirements().strip() - if requirements_text: - parts.append(f"Requirements:\n{requirements_text}") - except Exception: - pass - - if hasattr(node, "get_code"): - try: - code_text = node.get_code().strip() - if code_text: - parts.append(code_text) - except Exception: - pass - - for widget_name in ("output_display", "ai_analysis_display"): - widget = getattr(node, widget_name, None) - try: - text = widget.toPlainText().strip() if widget else "" - if text: - parts.append(text) - except Exception: - pass - - if hasattr(node, "instruction_input"): - try: - instruction = node.instruction_input.toPlainText().strip() - if instruction: - parts.append(instruction) - except Exception: - pass - - if hasattr(node, "get_artifact_content"): - try: - artifact_text = node.get_artifact_content().strip() - if artifact_text: - parts.append(artifact_text) - except Exception: - pass - - unique_parts = [] - seen = set() - for part in parts: - cleaned = _clean_text(part, limit=1200) - if cleaned and cleaned not in seen: - unique_parts.append(cleaned) - seen.add(cleaned) - return "\n\n".join(unique_parts) - - -def _collect_branch_nodes(node): - lineage = [] - seen = set() - cursor = node - while cursor and id(cursor) not in seen: - lineage.append(cursor) - seen.add(id(cursor)) - cursor = getattr(cursor, "parent_node", None) - lineage.reverse() - return lineage - - -def build_branch_payload(node, branch_name): - lineage = _collect_branch_nodes(node) - sections = [] - for index, branch_node in enumerate(lineage, start=1): - content = _extract_node_text(branch_node) - if not content: - continue - sections.append(f"Step {index}: {_node_label(branch_node)}\n{content}") - - transcript = "\n\n---\n\n".join(sections) if sections else "No branch transcript available." - label = f"{branch_name}: {_node_label(node)}" - preview = "\n\n".join(sections[-3:]) if sections else transcript - return { - "label": label, - "depth": len(lineage), - "transcript": transcript, - "preview": _clean_text(preview, limit=2800), - } - - -class GraphDiffAnalyzer: - SYSTEM_PROMPT = """ -You are Graphlink's Branch Lens. -Compare two distinct graph branches and explain how they diverge in logic, code direction, and intent. - -Rules: -1. Be concrete and specific. -2. Focus on divergence, not generic summaries. -3. If one branch is stronger, say so and explain why. -4. Output valid JSON only. No markdown fences. - -Return exactly: -{ - "overview": "2-4 sentence summary of how the branches differ", - "branch_a_focus": "What branch A is optimizing for", - "branch_b_focus": "What branch B is optimizing for", - "logic_differences": ["..."], - "code_differences": ["..."], - "intent_differences": ["..."], - "recommended_branch": "branch_a|branch_b|tie", - "recommendation_reason": "Why that branch is currently stronger", - "note_summary": "A concise note-ready summary of the divergence" -} -""" - - def _clean_json_response(self, raw_text): - block_match = re.search(r"```(?:json)?\s*(\{[\s\S]*\})\s*```", raw_text, re.IGNORECASE) - if block_match: - return block_match.group(1).strip() - json_match = re.search(r"(\{[\s\S]*\})", raw_text) - if json_match: - return json_match.group(1).strip() - return raw_text.strip() - - def _fallback_result(self, left_payload, right_payload): - left_text = left_payload["transcript"] - right_text = right_payload["transcript"] - similarity = difflib.SequenceMatcher(None, left_text, right_text).ratio() - left_lines = [line for line in left_text.splitlines() if line.strip()] - right_lines = [line for line in right_text.splitlines() if line.strip()] - diff_lines = list(difflib.unified_diff(left_lines, right_lines, lineterm="")) - trimmed_diff = "\n".join(diff_lines[:20]) if diff_lines else "The branches are structurally similar but vary in emphasis." - - logic_differences = [ - f"{left_payload['label']} depth: {left_payload['depth']} step(s)", - f"{right_payload['label']} depth: {right_payload['depth']} step(s)", - f"Similarity score: {similarity:.2f}", - ] - code_differences = [ - "Branch A appears more code-oriented." if re.search(r"code|python|function|class|html|script", left_text, re.IGNORECASE) else "Branch A is less code-heavy.", - "Branch B appears more code-oriented." if re.search(r"code|python|function|class|html|script", right_text, re.IGNORECASE) else "Branch B is less code-heavy.", - ] - intent_differences = [ - "Branch A leans more toward execution and implementation." if left_payload["depth"] >= right_payload["depth"] else "Branch A stays comparatively tighter in scope.", - "Branch B leans more toward exploration and variation." if right_payload["depth"] >= left_payload["depth"] else "Branch B stays comparatively tighter in scope.", - ] - - recommended_branch = "branch_a" if left_payload["depth"] >= right_payload["depth"] else "branch_b" - recommendation_reason = "The branch with more concrete downstream steps currently offers a clearer execution path." - - comparison_markdown = "\n".join([ - "# Graph Diff", - "", - "## Overview", - f"These branches are related but diverge in emphasis and downstream execution. Their textual similarity is approximately **{similarity:.2f}**, which suggests the paths share some context but have meaningfully different branch details.", - "", - "## Branch Focus", - f"- **Branch A:** {left_payload['label']}", - f"- **Branch B:** {right_payload['label']}", - "", - "## Logic Differences", - *[f"- {item}" for item in logic_differences], - "", - "## Code Differences", - *[f"- {item}" for item in code_differences], - "", - "## Intent Differences", - *[f"- {item}" for item in intent_differences], - "", - "## Raw Delta Snapshot", - "```diff", - trimmed_diff or "No line-level divergence detected.", - "```", - "", - "## Recommendation", - f"**{recommended_branch.replace('_', ' ').title()}** currently looks stronger because {recommendation_reason}", - ]) - - note_summary = "\n".join([ - "# Graph Diff Summary", - "", - f"- Branch A: {left_payload['label']}", - f"- Branch B: {right_payload['label']}", - f"- Key divergence: Similarity score {similarity:.2f} with different branch depth and emphasis.", - f"- Recommendation: {recommended_branch.replace('_', ' ').title()} is currently stronger.", - ]) - - return { - "comparison_markdown": comparison_markdown, - "note_summary": note_summary, - } - - def get_response(self, left_payload, right_payload): - user_prompt = f""" -Branch A Label: -{left_payload['label']} - -Branch A Transcript: -{left_payload['transcript']} - -Branch B Label: -{right_payload['label']} - -Branch B Transcript: -{right_payload['transcript']} -""" - try: - response = api_provider.chat( - task=config.TASK_CHAT, - messages=[ - {"role": "system", "content": self.SYSTEM_PROMPT}, - {"role": "user", "content": user_prompt}, - ], - ) - raw_text = response["message"]["content"] - parsed = json.loads(self._clean_json_response(raw_text)) - logic = [str(item).strip() for item in parsed.get("logic_differences", []) if str(item).strip()] - code = [str(item).strip() for item in parsed.get("code_differences", []) if str(item).strip()] - intent = [str(item).strip() for item in parsed.get("intent_differences", []) if str(item).strip()] - recommended = str(parsed.get("recommended_branch", "tie")).strip() - reason = str(parsed.get("recommendation_reason", "")).strip() - - comparison_markdown = "\n".join([ - "# Graph Diff", - "", - "## Overview", - str(parsed.get("overview", "")).strip() or "The two branches diverge in meaningful ways.", - "", - "## Branch Focus", - f"- **Branch A:** {str(parsed.get('branch_a_focus', '')).strip() or left_payload['label']}", - f"- **Branch B:** {str(parsed.get('branch_b_focus', '')).strip() or right_payload['label']}", - "", - "## Logic Differences", - *([f"- {item}" for item in logic] or ["- No major logic divergence detected."]), - "", - "## Code Differences", - *([f"- {item}" for item in code] or ["- No major code divergence detected."]), - "", - "## Intent Differences", - *([f"- {item}" for item in intent] or ["- No major intent divergence detected."]), - "", - "## Recommendation", - f"**{recommended.replace('_', ' ').title()}**", - reason or "Neither branch clearly dominates yet.", - ]) - - note_summary = str(parsed.get("note_summary", "")).strip() or comparison_markdown - return { - "comparison_markdown": comparison_markdown, - "note_summary": note_summary, - } - except Exception: - return self._fallback_result(left_payload, right_payload) - - -class GraphDiffWorkerThread(QThread): - finished = Signal(dict) - error = Signal(str) - - def __init__(self, left_payload, right_payload): - super().__init__() - self.left_payload = left_payload - self.right_payload = right_payload - self.agent = GraphDiffAnalyzer() - self._is_running = True - - def run(self): - try: - if not self._is_running: - return - result = self.agent.get_response(self.left_payload, self.right_payload) - if self._is_running: - self.finished.emit(result) - except Exception as exc: - if self._is_running: - self.error.emit(str(exc)) - finally: - self._is_running = False - - def stop(self): - self._is_running = False - - -class GraphDiffConnectionItem(ConnectionItem): - def paint(self, painter, option, widget=None): - if not (self.start_node and self.end_node): - return - - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - node_color = get_semantic_color("status_warning") - pen = QPen(node_color, 2, Qt.PenStyle.DashLine) - pen.setCapStyle(Qt.PenCapStyle.RoundCap) - - if self.hover: - pen.setWidth(3) - - painter.setPen(pen) - painter.drawPath(self.path) - - if self.is_animating: - for arrow in self.arrows: - self.drawArrow(painter, arrow["pos"], node_color) - - def drawArrow(self, painter, pos, color): - if pos < 0 or pos > 1: - return - point = self.path.pointAtPercent(pos) - angle = self.path.angleAtPercent(pos) - - arrow = QPainterPath() - arrow.moveTo(-self.arrow_size, -self.arrow_size / 2) - arrow.lineTo(0, 0) - arrow.lineTo(-self.arrow_size, self.arrow_size / 2) - - painter.save() - painter.translate(point) - painter.rotate(-angle) - painter.setBrush(color) - painter.setPen(Qt.PenStyle.NoPen) - painter.drawPath(arrow) - painter.restore() - - -class GraphDiffNode(QGraphicsObject, HoverAnimationMixin): - compare_requested = Signal(object) - note_requested = Signal(object) - - NODE_WIDTH = 920 - NODE_HEIGHT = 720 - COLLAPSED_WIDTH = 280 - COLLAPSED_HEIGHT = 40 - CONNECTION_DOT_RADIUS = 5 - CONNECTION_DOT_OFFSET = 0 - - def __init__(self, left_source_node, right_source_node, parent=None): - super().__init__(parent) - HoverAnimationMixin.__init__(self) - self.left_source_node = left_source_node - self.right_source_node = right_source_node - self.parent_node = None - self.children = [] - self.status = "Idle" - self.comparison_markdown = "" - self.note_summary = "" - self.left_branch_payload = build_branch_payload(left_source_node, "Branch A") - self.right_branch_payload = build_branch_payload(right_source_node, "Branch B") - self.is_search_match = False - self.is_collapsed = False - self.is_disposed = False - self.worker_thread = None - self.collapse_button_rect = QRectF() - - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemIsMovable) - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemIsSelectable) - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemSendsGeometryChanges) - self.setAcceptHoverEvents(True) - self.hovered = False - - self.widget = QWidget() - self.widget.setObjectName("graphDiffMainWidget") - self.widget.setFixedSize(self.NODE_WIDTH, self.NODE_HEIGHT) - - self._setup_ui() - - self.proxy = QGraphicsProxyWidget(self) - self.proxy.setWidget(self.widget) - - @property - def width(self): - return self.COLLAPSED_WIDTH if self.is_collapsed else self.NODE_WIDTH - - @property - def height(self): - return self.COLLAPSED_HEIGHT if self.is_collapsed else self.NODE_HEIGHT - - def set_collapsed(self, collapsed): - if self.is_collapsed != collapsed: - self.is_collapsed = collapsed - self.proxy.setVisible(not self.is_collapsed) - self.prepareGeometryChange() - if self.scene(): - self.scene().update_connections() - self.scene().nodeMoved(self) - self.update() - - def toggle_collapse(self): - self.set_collapsed(not self.is_collapsed) - - def _setup_ui(self): - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Orange"]["color"]) - brightness = (node_color.red() * 299 + node_color.green() * 587 + node_color.blue() * 114) / 1000 - button_text_color = "black" if brightness > 128 else "white" - - self.widget.setStyleSheet(f""" - QWidget#graphDiffMainWidget {{ - background-color: transparent; - color: #e0e0e0; - font-family: 'Segoe UI', sans-serif; - }} - QWidget#graphDiffMainWidget QLabel {{ - background-color: transparent; - }} - QFrame#graphDiffBranchCard, QFrame#graphDiffSummaryCard {{ - background-color: #1f2328; - border: 1px solid #343a41; - border-radius: 8px; - }} - QLabel#graphDiffSectionTitle {{ - color: #ffffff; - font-size: 12px; - font-weight: bold; - }} - QLabel#graphDiffBadge {{ - color: {node_color.name()}; - background-color: rgba(243, 156, 18, 0.12); - border: 1px solid rgba(243, 156, 18, 0.28); - border-radius: 10px; - padding: 3px 8px; - font-size: 11px; - font-weight: bold; - }} - QTextEdit {{ - background-color: #15181b; - border: 1px solid #2f353d; - color: #d7dce2; - border-radius: 7px; - padding: 8px; - selection-background-color: #264f78; - }} - QTextEdit:focus {{ - border: 1px solid {node_color.name()}; - }} - """) - - main_layout = QVBoxLayout(self.widget) - main_layout.setContentsMargins(15, 15, 15, 15) - main_layout.setSpacing(10) - - header_layout = QHBoxLayout() - icon = QLabel() - icon.setPixmap(qta.icon("fa5s.code-branch", color=node_color).pixmap(18, 18)) - header_layout.addWidget(icon) - - title_label = QLabel("Branch Lens") - title_label.setStyleSheet(f"font-weight: bold; font-size: 14px; color: {node_color.name()}; background: transparent;") - header_layout.addWidget(title_label) - header_layout.addStretch() - main_layout.addLayout(header_layout) - - line = QFrame() - line.setFrameShape(QFrame.Shape.HLine) - line.setStyleSheet("background-color: #3f3f3f; border: none; height: 1px;") - main_layout.addWidget(line) - - source_bar = QHBoxLayout() - source_bar.setSpacing(8) - self.left_branch_label = QLabel(self.left_branch_payload["label"]) - self.left_branch_label.setObjectName("graphDiffBadge") - source_bar.addWidget(self.left_branch_label) - self.right_branch_label = QLabel(self.right_branch_payload["label"]) - self.right_branch_label.setObjectName("graphDiffBadge") - source_bar.addWidget(self.right_branch_label) - source_bar.addStretch() - - self.status_label = QLabel("Idle") - self.status_label.setObjectName("graphDiffBadge") - source_bar.addWidget(self.status_label) - main_layout.addLayout(source_bar) - - controls_layout = QHBoxLayout() - controls_layout.setSpacing(8) - self.compare_button = QPushButton("Compare Branches") - self.compare_button.setIcon(qta.icon("fa5s.exchange-alt", color=button_text_color)) - self.compare_button.clicked.connect(lambda: self.compare_requested.emit(self)) - controls_layout.addWidget(self.compare_button) - - self.note_button = QPushButton("Create Summary Note") - self.note_button.setIcon(qta.icon("fa5s.sticky-note", color=button_text_color)) - self.note_button.clicked.connect(lambda: self.note_requested.emit(self)) - self.note_button.setEnabled(False) - controls_layout.addWidget(self.note_button) - controls_layout.addStretch() - main_layout.addLayout(controls_layout) - - branch_compare_widget = QWidget() - branch_compare_layout = QHBoxLayout(branch_compare_widget) - branch_compare_layout.setContentsMargins(0, 0, 0, 0) - branch_compare_layout.setSpacing(10) - - left_card = QFrame() - left_card.setObjectName("graphDiffBranchCard") - left_layout = QVBoxLayout(left_card) - left_layout.setContentsMargins(12, 10, 12, 10) - left_layout.setSpacing(6) - left_title = QLabel("Branch A Snapshot") - left_title.setObjectName("graphDiffSectionTitle") - left_layout.addWidget(left_title) - self.left_preview = QTextEdit() - self.left_preview.setReadOnly(True) - self.left_preview.setStyleSheet("QTextEdit { font-size: 12px; }" + GRAPH_DIFF_SCROLLBAR_STYLE) - left_layout.addWidget(self.left_preview) - - right_card = QFrame() - right_card.setObjectName("graphDiffBranchCard") - right_layout = QVBoxLayout(right_card) - right_layout.setContentsMargins(12, 10, 12, 10) - right_layout.setSpacing(6) - right_title = QLabel("Branch B Snapshot") - right_title.setObjectName("graphDiffSectionTitle") - right_layout.addWidget(right_title) - self.right_preview = QTextEdit() - self.right_preview.setReadOnly(True) - self.right_preview.setStyleSheet("QTextEdit { font-size: 12px; }" + GRAPH_DIFF_SCROLLBAR_STYLE) - right_layout.addWidget(self.right_preview) - - branch_compare_layout.addWidget(left_card, 1) - branch_compare_layout.addWidget(right_card, 1) - main_layout.addWidget(branch_compare_widget, 1) - - summary_card = QFrame() - summary_card.setObjectName("graphDiffSummaryCard") - summary_layout = QVBoxLayout(summary_card) - summary_layout.setContentsMargins(12, 10, 12, 10) - summary_layout.setSpacing(6) - summary_title = QLabel("Divergence Summary") - summary_title.setObjectName("graphDiffSectionTitle") - summary_layout.addWidget(summary_title) - self.diff_display = QTextEdit() - self.diff_display.setReadOnly(True) - self.diff_display.setPlaceholderText("Run the diff checker to generate a side-by-side analysis of the two branches.") - self.diff_display.setStyleSheet("QTextEdit { font-size: 12px; }" + GRAPH_DIFF_SCROLLBAR_STYLE) - summary_layout.addWidget(self.diff_display) - main_layout.addWidget(summary_card, 1) - - self.compare_button.setStyleSheet(f""" - QPushButton {{ - background-color: {node_color.name()}; - color: {button_text_color}; - border: none; - border-radius: 6px; - padding: 9px 14px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: {node_color.lighter(110).name()}; - }} - QPushButton:disabled {{ - background-color: #555555; - color: #cccccc; - }} - """) - self.note_button.setStyleSheet(self.compare_button.styleSheet()) - - self.refresh_source_views() - self.set_status("Idle") - - def refresh_source_views(self): - self.left_branch_payload = build_branch_payload(self.left_source_node, "Branch A") - self.right_branch_payload = build_branch_payload(self.right_source_node, "Branch B") - self.left_branch_label.setText(self.left_branch_payload["label"]) - self.right_branch_label.setText(self.right_branch_payload["label"]) - self.left_preview.setPlainText(self.left_branch_payload["preview"]) - self.right_preview.setPlainText(self.right_branch_payload["preview"]) - - def get_comparison_payloads(self): - self.refresh_source_views() - return self.left_branch_payload, self.right_branch_payload - - def set_running_state(self, is_running): - if self.is_disposed: - return - self.compare_button.setEnabled(not is_running) - self.note_button.setEnabled(bool(self.note_summary.strip()) and not is_running) - if is_running: - self.set_status("Comparing...") - elif self.status.startswith("Error"): - return - elif self.comparison_markdown.strip(): - self.set_status("Completed") - else: - self.set_status("Idle") - - def set_status(self, status_text): - if self.is_disposed: - return - self.status = status_text - self.status_label.setText(status_text) - if "Comparing" in status_text: - info_color = get_semantic_color("status_info") - self.status_label.setStyleSheet(f"color: {info_color.name()}; background-color: rgba({info_color.red()}, {info_color.green()}, {info_color.blue()}, 0.1); border: 1px solid rgba({info_color.red()}, {info_color.green()}, {info_color.blue()}, 0.22); border-radius: 10px; padding: 3px 8px; font-size: 11px; font-weight: bold;") - elif "Completed" in status_text or "Ready" in status_text: - warning_color = get_semantic_color("status_warning") - self.status_label.setStyleSheet(f"color: {warning_color.name()}; background-color: rgba({warning_color.red()}, {warning_color.green()}, {warning_color.blue()}, 0.1); border: 1px solid rgba({warning_color.red()}, {warning_color.green()}, {warning_color.blue()}, 0.22); border-radius: 10px; padding: 3px 8px; font-size: 11px; font-weight: bold;") - elif "Error" in status_text: - error_color = get_semantic_color("status_error") - self.status_label.setStyleSheet(f"color: {error_color.name()}; background-color: rgba({error_color.red()}, {error_color.green()}, {error_color.blue()}, 0.1); border: 1px solid rgba({error_color.red()}, {error_color.green()}, {error_color.blue()}, 0.22); border-radius: 10px; padding: 3px 8px; font-size: 11px; font-weight: bold;") - else: - self.status_label.setStyleSheet("color: #9aa3ad; background-color: rgba(154, 163, 173, 0.08); border: 1px solid rgba(154, 163, 173, 0.18); border-radius: 10px; padding: 3px 8px; font-size: 11px; font-weight: bold;") - - def set_result(self, result): - if self.is_disposed: - return - self.comparison_markdown = result.get("comparison_markdown", "") - self.note_summary = result.get("note_summary", "") - self.diff_display.setMarkdown(self.comparison_markdown) - self.note_button.setEnabled(bool(self.note_summary.strip())) - self.set_status("Completed") - - def set_error(self, error_message): - if self.is_disposed: - return - self.comparison_markdown = f"## Error\n\n{error_message}" - self.note_summary = "" - self.diff_display.setMarkdown(self.comparison_markdown) - self.note_button.setEnabled(False) - self.set_status(f"Error: {error_message}") - - def dispose(self): - if self.is_disposed: - return - - self.is_disposed = True - - worker_thread = self.worker_thread - self.worker_thread = None - if worker_thread: - try: - worker_thread.stop() - except Exception: - pass - for signal in (worker_thread.finished, worker_thread.error): - try: - signal.disconnect() - except (TypeError, RuntimeError): - pass - try: - if worker_thread.isRunning(): - worker_thread.finished.connect(worker_thread.deleteLater) - else: - worker_thread.deleteLater() - except RuntimeError: - pass - - proxy = getattr(self, "proxy", None) - if proxy is not None: - try: - embedded_widget = proxy.widget() - except RuntimeError: - embedded_widget = None - try: - proxy.setWidget(None) - except RuntimeError: - pass - if embedded_widget is not None: - try: - embedded_widget.hide() - embedded_widget.deleteLater() - except RuntimeError: - pass - - def boundingRect(self): - padding = self.CONNECTION_DOT_OFFSET + self.CONNECTION_DOT_RADIUS - return QRectF(-padding, 0, self.width + 2 * padding, self.height) - - def paint(self, painter, option, widget=None): - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Orange"]["color"]) - - path = QPainterPath() - path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2d2d2d")) - - pen = QPen(node_color, 1.5) - if self.isSelected(): - pen = QPen(palette.SELECTION, 2) - elif self.is_search_match: - pen = QPen(get_semantic_color("search_highlight"), 2) - elif self.hovered: - pen = QPen(QColor("#ffffff"), 2) - - painter.setPen(pen) - painter.drawPath(path) - - dot_color = node_color if not (self.isSelected() or self.hovered) else pen.color().lighter(110) - painter.setBrush(dot_color) - painter.setPen(Qt.PenStyle.NoPen) - - dot_rect_left = QRectF(-self.CONNECTION_DOT_RADIUS, (self.height / 2) - self.CONNECTION_DOT_RADIUS, self.CONNECTION_DOT_RADIUS * 2, self.CONNECTION_DOT_RADIUS * 2) - painter.drawPie(dot_rect_left, 90 * 16, -180 * 16) - - dot_rect_right = QRectF(self.width - self.CONNECTION_DOT_RADIUS, (self.height / 2) - self.CONNECTION_DOT_RADIUS, self.CONNECTION_DOT_RADIUS * 2, self.CONNECTION_DOT_RADIUS * 2) - painter.drawPie(dot_rect_right, 90 * 16, 180 * 16) - - if self.is_collapsed: - painter.setPen(QColor("#ffffff")) - painter.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold)) - painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "Branch Lens") - qta.icon("fa5s.code-branch", color=node_color.name()).paint(painter, QRect(10, 10, 20, 20)) - self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - qta.icon("fa5s.expand-arrows-alt", color="#ffffff" if self.hovered else "#888888").paint(painter, QRect(int(self.width - 30), 10, 20, 20)) - else: - if self.hovered: - self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - painter.setBrush(QColor(255, 255, 255, 30)) - painter.setPen(QColor(255, 255, 255, 150)) - painter.drawRoundedRect(self.collapse_button_rect.adjusted(6, 6, -6, -6), 4, 4) - painter.setPen(QPen(QColor("#ffffff"), 2)) - center = self.collapse_button_rect.center() - painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) - else: - self.collapse_button_rect = QRectF() - - def mousePressEvent(self, event): - if self.collapse_button_rect.contains(event.pos()): - self.toggle_collapse() - event.accept() - return - - if event.button() == Qt.MouseButton.LeftButton and self.scene(): - self.scene().is_dragging_item = True - if hasattr(self.scene(), "window"): - self.scene().window.setCurrentNode(self) - super().mousePressEvent(event) - - def contextMenuEvent(self, event): - menu = PluginNodeContextMenu(self) - menu.exec(event.screenPos()) - - def mouseReleaseEvent(self, event): - if self.scene(): - self.scene().is_dragging_item = False - self.scene()._clear_smart_guides() - super().mouseReleaseEvent(event) - - def itemChange(self, change, value): - if change == QGraphicsObject.GraphicsItemChange.ItemPositionChange and self.scene() and self.scene().is_dragging_item: - return self.scene().snap_position(self, value) - if change == QGraphicsObject.GraphicsItemChange.ItemPositionHasChanged and self.scene(): - self.scene().nodeMoved(self) - return super().itemChange(change, value) - - def hoverEnterEvent(self, event): - self._handle_hover_enter(event) - super().hoverEnterEvent(event) - - def hoverLeaveEvent(self, event): - self._handle_hover_leave(event) - super().hoverLeaveEvent(event) diff --git a/graphite_app/graphite_plugins/graphite_plugin_portal.py b/graphite_app/graphite_plugins/graphite_plugin_portal.py index e83fb51..3e0f1ee 100644 --- a/graphite_app/graphite_plugins/graphite_plugin_portal.py +++ b/graphite_app/graphite_plugins/graphite_plugin_portal.py @@ -13,12 +13,7 @@ from graphite_node import ChatNode, CodeNode from graphite_web import WebNode, WebConnectionItem from graphite_conversation_node import ConversationNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningNode, ReasoningConnectionItem from graphite_html_view import HtmlViewNode -from graphite_plugins.graphite_plugin_workflow import WorkflowNode, WorkflowConnectionItem -from graphite_plugins.graphite_plugin_graph_diff import GraphDiffNode, GraphDiffConnectionItem -from graphite_plugins.graphite_plugin_quality_gate import QualityGateNode, QualityGateConnectionItem -from graphite_plugins.graphite_plugin_code_review import CodeReviewNode, CodeReviewConnectionItem from graphite_plugins.graphite_plugin_gitlink import GitlinkNode, GitlinkConnectionItem from graphite_plugins.graphite_plugin_artifact import ArtifactNode, ArtifactConnectionItem from graphite_memory import clone_history @@ -47,8 +42,8 @@ class PluginSpec: connection_cls: Optional[type] # Whether graphite_window_actions.py's instantiate_seeded_plugin/_seed_plugin_prompt # flow can create this plugin from a single parent node plus a starter prompt. False - # for plugins with a different creation contract (System Prompt attaches to the graph - # root; Branch Lens/GraphDiffNode requires exactly two pre-selected existing nodes). + # for plugins with a different creation contract (e.g. System Prompt attaches to the + # graph root rather than branching from a single selected parent). seedable: bool @@ -73,36 +68,12 @@ def _register_spec(**kwargs): category="Branch Foundations", icon="fa5s.comments", node_cls=ConversationNode, connection_cls=ConversationConnectionItem, seedable=True, ) -_register_spec( - key="reasoning", display_name="Graphlink-Reasoning", - description="A multi-step reasoning agent for solving complex problems.", - category="Reasoning & Research", icon="fa5s.brain", - node_cls=ReasoningNode, connection_cls=ReasoningConnectionItem, seedable=True, -) _register_spec( key="web", display_name="Graphlink-Web", description="Adds a node with web access for real-time information retrieval.", category="Reasoning & Research", icon="fa5s.globe", node_cls=WebNode, connection_cls=WebConnectionItem, seedable=True, ) -_register_spec( - key="graph_diff", display_name="Branch Lens", - description="Compares two selected graph branches side-by-side and highlights where their logic, code, and intent diverge.", - category="Validation & Delivery", icon="fa5s.code-branch", - node_cls=GraphDiffNode, connection_cls=GraphDiffConnectionItem, seedable=False, -) -_register_spec( - key="quality_gate", display_name="Quality Gate", - description="Runs a production-readiness review on the current branch, scores its readiness, and recommends the highest-value remediation nodes.", - category="Validation & Delivery", icon="fa5s.check-circle", - node_cls=QualityGateNode, connection_cls=QualityGateConnectionItem, seedable=True, -) -_register_spec( - key="code_review", display_name="Code Review Agent", - description="Reviews a local or GitHub source file with deterministic scoring, structured findings, and a weighted code quality report.", - category="Validation & Delivery", icon="fa5s.search", - node_cls=CodeReviewNode, connection_cls=CodeReviewConnectionItem, seedable=True, -) _register_spec( key="gitlink", display_name="Gitlink", description="Loads a GitHub repository into structured XML context, prepares file-level changes, and only writes after explicit approval.", @@ -127,12 +98,6 @@ def _register_spec(**kwargs): category="Build & Execution", icon="fa5s.window-maximize", node_cls=HtmlViewNode, connection_cls=HtmlConnectionItem, seedable=True, ) -_register_spec( - key="workflow", display_name="Workflow Architect", - description="Designs an agentic execution plan, recommends the right specialist plugins, and seeds follow-up nodes.", - category="Workflow & Drafting", icon="fa5s.project-diagram", - node_cls=WorkflowNode, connection_cls=WorkflowConnectionItem, seedable=True, -) _register_spec( key="artifact", display_name="Artifact / Drafter", description="A split-pane node for iteratively drafting and refining living documents (Markdown).", @@ -224,14 +189,6 @@ def _discover_plugins(self): icon='fa5s.comments', ) - self._register_plugin( - name='Graphlink-Reasoning', - description='A multi-step reasoning agent for solving complex problems.', - callback=self._create_reasoning_node, - category='Reasoning & Research', - icon='fa5s.brain', - ) - self._register_plugin( name='Graphlink-Web', description='Adds a node with web access for real-time information retrieval.', @@ -240,30 +197,6 @@ def _discover_plugins(self): icon='fa5s.globe', ) - self._register_plugin( - name='Branch Lens', - description='Compares two selected graph branches side-by-side and highlights where their logic, code, and intent diverge.', - callback=self._create_graph_diff_node, - category='Validation & Delivery', - icon='fa5s.code-branch', - ) - - self._register_plugin( - name='Quality Gate', - description='Runs a production-readiness review on the current branch, scores its readiness, and recommends the highest-value remediation nodes.', - callback=self._create_quality_gate_node, - category='Validation & Delivery', - icon='fa5s.check-circle', - ) - - self._register_plugin( - name='Code Review Agent', - description='Reviews a local or GitHub source file with deterministic scoring, structured findings, and a weighted code quality report.', - callback=self._create_code_review_node, - category='Validation & Delivery', - icon='fa5s.search', - ) - self._register_plugin( name='Gitlink', description='Loads a GitHub repository into structured XML context, prepares file-level changes, and only writes after explicit approval.', @@ -296,14 +229,6 @@ def _discover_plugins(self): icon='fa5s.window-maximize', ) - self._register_plugin( - name='Workflow Architect', - description='Designs an agentic execution plan, recommends the right specialist plugins, and seeds follow-up nodes.', - callback=self._create_workflow_node, - category='Workflow & Drafting', - icon='fa5s.project-diagram', - ) - self._register_plugin( name='Artifact / Drafter', description='A split-pane node for iteratively drafting and refining living documents (Markdown).', @@ -409,9 +334,8 @@ def create_node( the node and its connection with the scene's existing (still hardcoded, see PLUGIN_SYSTEM_REFACTOR_PLAN.md section 3.3) node/connection lists. - Plugins with a genuinely different creation contract don't use this - System - Prompt attaches to the graph root rather than branching from a selection, and - Branch Lens/GraphDiffNode requires exactly two pre-selected existing nodes - they + Plugins with a genuinely different creation contract don't use this - e.g. System + Prompt attaches to the graph root rather than branching from a selection - they keep their own bespoke factory methods below. `validate_parent`, if given, replaces the default `hasattr(parent_node, @@ -538,57 +462,6 @@ def _wire(node): invalid_parent_message="Artifact Drafter can only branch from a valid conversational node.", ) - def _create_workflow_node(self): - scene = self.main_window.chat_view.scene() - - def _wire(node): - node.workflow_requested.connect(self.main_window.execute_workflow_node) - node.plugin_requested.connect(self.main_window.instantiate_seeded_plugin) - - return self.create_node( - node_cls=WorkflowNode, - connection_cls=WorkflowConnectionItem, - scene_nodes=scene.workflow_nodes, - scene_connections=scene.workflow_connections, - wire=_wire, - clone_parent_history=True, - resolve_branch_parent=False, - no_selection_message="Please select a valid node to branch from before adding Workflow Architect.", - ) - - def _create_quality_gate_node(self): - scene = self.main_window.chat_view.scene() - - def _wire(node): - node.review_requested.connect(self.main_window.execute_quality_gate_node) - node.plugin_requested.connect(self.main_window.instantiate_seeded_plugin) - node.note_requested.connect(self.main_window.create_quality_gate_note) - - return self.create_node( - node_cls=QualityGateNode, - connection_cls=QualityGateConnectionItem, - scene_nodes=scene.quality_gate_nodes, - scene_connections=scene.quality_gate_connections, - wire=_wire, - clone_parent_history=True, - resolve_branch_parent=False, - no_selection_message="Please select a valid node to branch from before adding Quality Gate.", - ) - - def _create_code_review_node(self): - scene = self.main_window.chat_view.scene() - return self.create_node( - node_cls=CodeReviewNode, - connection_cls=CodeReviewConnectionItem, - scene_nodes=scene.code_review_nodes, - scene_connections=scene.code_review_connections, - node_kwargs={"settings_manager": self.main_window.settings_manager}, - wire=lambda node: node.review_requested.connect(self.main_window.execute_code_review_node), - clone_parent_history=True, - no_selection_message="Please select a node to branch from before adding Code Review Agent.", - invalid_parent_message="Code Review Agent can only branch from a valid conversational node.", - ) - def _create_gitlink_node(self): scene = self.main_window.chat_view.scene() return self.create_node( @@ -603,38 +476,6 @@ def _create_gitlink_node(self): invalid_parent_message="Gitlink can only branch from a valid conversational node.", ) - def _create_graph_diff_node(self): - from graphite_plugins.graphite_plugin_artifact import ArtifactNode - - scene = self.main_window.chat_view.scene() - valid_sources = (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, HtmlViewNode, WorkflowNode, ArtifactNode, QualityGateNode, CodeReviewNode, GitlinkNode) - selected_nodes = [item for item in scene.selectedItems() if isinstance(item, valid_sources)] - - if len(selected_nodes) != 2: - self.main_window.notification_banner.show_message("Select exactly two branch-tip nodes before adding Branch Lens.", 5000, "warning") - return None - - left_node, right_node = selected_nodes[:2] - diff_node = GraphDiffNode(left_node, right_node) - diff_node.compare_requested.connect(self.main_window.execute_graph_diff_node) - diff_node.note_requested.connect(self.main_window.create_graph_diff_note) - - left_width = left_node.width if hasattr(left_node, 'width') else left_node.boundingRect().width() - right_width = right_node.width if hasattr(right_node, 'width') else right_node.boundingRect().width() - spawn_x = max(left_node.scenePos().x() + left_width, right_node.scenePos().x() + right_width) + 160 - spawn_y = (left_node.scenePos().y() + right_node.scenePos().y()) / 2 - self._position_free_node(scene, QPointF(spawn_x, spawn_y), diff_node, strategy="branch") - - scene.addItem(diff_node) - scene.graph_diff_nodes.append(diff_node) - - for source_node in (left_node, right_node): - connection = GraphDiffConnectionItem(source_node, diff_node) - scene.addItem(connection) - scene.graph_diff_connections.append(connection) - - return diff_node - def _create_web_node(self): scene = self.main_window.chat_view.scene() return self.create_node( @@ -670,27 +511,10 @@ def _wire(node): no_selection_message="Please select a valid node to branch from before adding a Conversation Node.", ) - def _create_reasoning_node(self): - scene = self.main_window.chat_view.scene() - - def _wire(node): - node.reasoning_requested.connect(self.main_window.execute_reasoning_node) - node.stop_requested.connect(self.main_window.stop_reasoning_node) - - return self.create_node( - node_cls=ReasoningNode, - connection_cls=ReasoningConnectionItem, - scene_nodes=scene.reasoning_nodes, - scene_connections=scene.reasoning_connections, - wire=_wire, - resolve_branch_parent=False, - no_selection_message="Please select a valid node to branch from before adding a Reasoning Node.", - ) - def _create_html_view_node(self): scene = self.main_window.chat_view.scene() selected_node = self.main_window.current_node - valid_parents = (ChatNode, CodeNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, WorkflowNode, QualityGateNode, CodeReviewNode, GitlinkNode) + valid_parents = (ChatNode, CodeNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, GitlinkNode) def _wire(node): if isinstance(selected_node, CodeNode): diff --git a/graphite_app/graphite_plugins/graphite_plugin_quality_gate.py b/graphite_app/graphite_plugins/graphite_plugin_quality_gate.py deleted file mode 100644 index d017ab1..0000000 --- a/graphite_app/graphite_plugins/graphite_plugin_quality_gate.py +++ /dev/null @@ -1,926 +0,0 @@ -from PySide6.QtCore import QRect, QRectF, Qt, QThread, Signal -from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen -from PySide6.QtWidgets import ( - QFrame, - QGraphicsObject, - QGraphicsProxyWidget, - QHBoxLayout, - QLabel, - QPushButton, - QScrollArea, - QTabWidget, - QTextEdit, - QVBoxLayout, - QWidget, -) -import qtawesome as qta - -from graphite_canvas_items import HoverAnimationMixin -from graphite_config import get_current_palette, get_semantic_color -from graphite_connections import ConnectionItem -from graphite_plugins.graphite_plugin_context_menu import PluginNodeContextMenu -from graphite_plugins.quality_gate.scoring import ( - QUALITY_GATE_PLUGIN_ICONS, - QualityGateAnalyzer, - build_quality_gate_payload, -) - - -QUALITY_GATE_SCROLLBAR_STYLE = """ - QScrollBar:vertical { - background: #1a1d20; - width: 10px; - margin: 0px; - border-radius: 5px; - } - QScrollBar::handle:vertical { - background-color: #555b63; - min-height: 25px; - border-radius: 5px; - } - QScrollBar::handle:vertical:hover { - background-color: #6a727c; - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - height: 0px; - background: none; - } - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { - background: none; - } - QScrollBar:horizontal { - background: #1a1d20; - height: 10px; - margin: 0px; - border-radius: 5px; - } - QScrollBar::handle:horizontal { - background-color: #555b63; - min-width: 25px; - border-radius: 5px; - } - QScrollBar::handle:horizontal:hover { - background-color: #6a727c; - } - QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { - width: 0px; - background: none; - } - QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { - background: none; - } -""" - - - -class QualityGateWorkerThread(QThread): - finished = Signal(dict) - error = Signal(str) - - def __init__(self, goal, criteria, payload): - super().__init__() - self.goal = goal - self.criteria = criteria - self.payload = payload - self.agent = QualityGateAnalyzer() - self._is_running = True - - def run(self): - try: - if not self._is_running: - return - result = self.agent.get_response(self.goal, self.criteria, self.payload) - if self._is_running: - self.finished.emit(result) - except Exception as exc: - if self._is_running: - self.error.emit(str(exc)) - finally: - self._is_running = False - - def stop(self): - self._is_running = False - - -class QualityGateRecommendationCard(QFrame): - add_requested = Signal(str, str) - - def __init__(self, recommendation, accent_color, parent=None): - super().__init__(parent) - self.recommendation = recommendation - self.setObjectName("qualityGateRecommendationCard") - self.setStyleSheet(f""" - QFrame#qualityGateRecommendationCard {{ - background-color: #24282d; - border: 1px solid #353b43; - border-radius: 8px; - }} - QLabel {{ - background: transparent; - color: #d7dce2; - }} - QLabel#qualityGatePluginTitle {{ - color: #ffffff; - font-weight: bold; - font-size: 13px; - }} - QLabel#qualityGateMeta {{ - color: {accent_color}; - background-color: transparent; - font-size: 10px; - font-weight: bold; - }} - QLabel#qualityGateReason {{ - color: #c8d0d8; - font-size: 12px; - }} - QLabel#qualityGatePrompt {{ - color: #a9b4bf; - font-size: 11px; - padding-top: 4px; - border-top: 1px solid #31363d; - }} - QPushButton {{ - background-color: {accent_color}; - color: black; - border: none; - border-radius: 6px; - padding: 6px 12px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: #f7d85b; - }} - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(12, 10, 12, 10) - layout.setSpacing(8) - - header_layout = QHBoxLayout() - header_layout.setContentsMargins(0, 0, 0, 0) - icon_label = QLabel() - icon_name = QUALITY_GATE_PLUGIN_ICONS.get(recommendation["plugin"], "fa5s.puzzle-piece") - icon_label.setPixmap(qta.icon(icon_name, color=accent_color).pixmap(16, 16)) - header_layout.addWidget(icon_label) - - title_label = QLabel(recommendation["plugin"]) - title_label.setObjectName("qualityGatePluginTitle") - header_layout.addWidget(title_label) - header_layout.addStretch() - - priority_label = QLabel(recommendation["priority"].upper()) - priority_label.setObjectName("qualityGateMeta") - header_layout.addWidget(priority_label) - layout.addLayout(header_layout) - - why_label = QLabel(recommendation["why"]) - why_label.setObjectName("qualityGateReason") - why_label.setWordWrap(True) - layout.addWidget(why_label) - - prompt_label = QLabel(f"Seed: {recommendation['starter_prompt']}") - prompt_label.setObjectName("qualityGatePrompt") - prompt_label.setWordWrap(True) - layout.addWidget(prompt_label) - - action_layout = QHBoxLayout() - action_layout.setContentsMargins(0, 0, 0, 0) - action_layout.addStretch() - add_button = QPushButton("Add Node") - add_button.clicked.connect(lambda: self.add_requested.emit(recommendation["plugin"], recommendation["starter_prompt"])) - action_layout.addWidget(add_button) - layout.addLayout(action_layout) - - -class QualityGateConnectionItem(ConnectionItem): - def paint(self, painter, option, widget=None): - if not (self.start_node and self.end_node): - return - - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Yellow"]["color"]) - pen = QPen(node_color, 2, Qt.PenStyle.DashLine) - pen.setCapStyle(Qt.PenCapStyle.RoundCap) - - if self.hover: - pen.setWidth(3) - - painter.setPen(pen) - painter.drawPath(self.path) - - if self.is_animating: - for arrow in self.arrows: - self.drawArrow(painter, arrow["pos"], node_color) - - def drawArrow(self, painter, pos, color): - if pos < 0 or pos > 1: - return - point = self.path.pointAtPercent(pos) - angle = self.path.angleAtPercent(pos) - - arrow = QPainterPath() - arrow.moveTo(-self.arrow_size, -self.arrow_size / 2) - arrow.lineTo(0, 0) - arrow.lineTo(-self.arrow_size, self.arrow_size / 2) - - painter.save() - painter.translate(point) - painter.rotate(-angle) - painter.setBrush(color) - painter.setPen(Qt.PenStyle.NoPen) - painter.drawPath(arrow) - painter.restore() - - -class QualityGateNode(QGraphicsObject, HoverAnimationMixin): - supports_branch_context_toggle = True - review_requested = Signal(object) - plugin_requested = Signal(object, str, str) - note_requested = Signal(object) - - NODE_WIDTH = 820 - NODE_HEIGHT = 760 - COLLAPSED_WIDTH = 260 - COLLAPSED_HEIGHT = 40 - CONNECTION_DOT_RADIUS = 5 - CONNECTION_DOT_OFFSET = 0 - - def __init__(self, parent_node, parent=None): - super().__init__(parent) - HoverAnimationMixin.__init__(self) - self.parent_node = parent_node - self.children = [] - self.is_user = False - self.conversation_history = [] - self.goal = "" - self.criteria = "" - self.status = "Idle" - self.verdict = "pending" - self.readiness_score = 0 - self.review_markdown = "" - self.note_summary = "" - self.recommendations = [] - self.is_search_match = False - self.is_collapsed = False - self.is_disposed = False - self.worker_thread = None - self.collapse_button_rect = QRectF() - - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemIsMovable) - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemIsSelectable) - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemSendsGeometryChanges) - self.setAcceptHoverEvents(True) - self.hovered = False - - self.widget = QWidget() - self.widget.setObjectName("qualityGateMainWidget") - self.widget.setFixedSize(self.NODE_WIDTH, self.NODE_HEIGHT) - self.widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - self.widget.setStyleSheet(""" - QWidget#qualityGateMainWidget { - background-color: transparent; - color: #e0e0e0; - } - QWidget#qualityGateMainWidget QLabel { - background-color: transparent; - } - """) - - self._setup_ui() - - self.proxy = QGraphicsProxyWidget(self) - self.proxy.setWidget(self.widget) - - @property - def width(self): - return self.COLLAPSED_WIDTH if self.is_collapsed else self.NODE_WIDTH - - @property - def height(self): - return self.COLLAPSED_HEIGHT if self.is_collapsed else self.NODE_HEIGHT - - def set_collapsed(self, collapsed): - if self.is_collapsed != collapsed: - self.is_collapsed = collapsed - self.proxy.setVisible(not self.is_collapsed) - self.prepareGeometryChange() - if self.scene(): - self.scene().update_connections() - self.scene().nodeMoved(self) - self.update() - - def toggle_collapse(self): - self.set_collapsed(not self.is_collapsed) - - def _setup_ui(self): - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Yellow"]["color"]) - brightness = (node_color.red() * 299 + node_color.green() * 587 + node_color.blue() * 114) / 1000 - button_text_color = "black" if brightness > 128 else "white" - badge_rgba = f"{node_color.red()}, {node_color.green()}, {node_color.blue()}" - - self.widget.setStyleSheet(f""" - QWidget#qualityGateMainWidget {{ - background-color: transparent; - color: #e0e0e0; - font-family: 'Segoe UI', sans-serif; - }} - QWidget#qualityGateMainWidget QLabel {{ - background-color: transparent; - }} - QFrame#qualityGateBriefShell {{ - background-color: #202327; - border: 1px solid #353b43; - border-radius: 10px; - }} - QLabel#qualityGateSectionTitle {{ - color: #ffffff; - font-size: 12px; - font-weight: bold; - }} - QLabel#qualityGateSectionHint {{ - color: #95a0ab; - font-size: 11px; - }} - QLabel#qualityGateFieldLabel {{ - color: #c7cdd4; - font-size: 11px; - font-weight: bold; - }} - QLabel#qualityGateMetricBadge {{ - color: {node_color.name()}; - background-color: rgba({badge_rgba}, 0.12); - border: 1px solid rgba({badge_rgba}, 0.26); - border-radius: 10px; - padding: 3px 8px; - font-size: 11px; - font-weight: bold; - }} - QTextEdit {{ - background-color: #15181b; - border: 1px solid #2f353d; - color: #d7dce2; - border-radius: 7px; - padding: 8px; - selection-background-color: #264f78; - font-family: 'Segoe UI', sans-serif; - }} - QTextEdit:focus {{ - border: 1px solid {node_color.name()}; - }} - QTabWidget::pane {{ - border: 1px solid #3a4048; - background: #1d2024; - border-radius: 8px; - }} - QTabBar::tab {{ - background: #25282d; - color: #97a1ab; - padding: 8px 14px; - border: 1px solid #3a4048; - border-bottom: none; - border-top-left-radius: 6px; - border-top-right-radius: 6px; - margin-right: 2px; - font-weight: bold; - }} - QTabBar::tab:selected {{ - background: #1d2024; - color: #ffffff; - border-top: 2px solid {node_color.name()}; - border-bottom: 1px solid #1d2024; - }} - QTabBar::tab:hover:!selected {{ - background: #2d3136; - color: #ffffff; - }} - """) - - main_layout = QVBoxLayout(self.widget) - main_layout.setContentsMargins(15, 15, 15, 15) - main_layout.setSpacing(10) - - header_layout = QHBoxLayout() - header_layout.setContentsMargins(4, 0, 4, 0) - header_layout.setSpacing(8) - icon = QLabel() - icon.setPixmap(qta.icon("fa5s.check-circle", color=node_color).pixmap(18, 18)) - header_layout.addWidget(icon) - title_label = QLabel("Quality Gate") - title_label.setStyleSheet(f"font-weight: bold; font-size: 14px; color: {node_color.name()};") - header_layout.addWidget(title_label) - header_layout.addStretch() - main_layout.addLayout(header_layout) - - line = QFrame() - line.setFrameShape(QFrame.Shape.HLine) - line.setStyleSheet("background-color: #3f3f3f; border: none; height: 1px;") - main_layout.addWidget(line) - - briefing_surface = QFrame() - briefing_surface.setObjectName("qualityGateBriefShell") - briefing_surface.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - briefing_layout = QVBoxLayout(briefing_surface) - briefing_layout.setContentsMargins(14, 12, 14, 12) - briefing_layout.setSpacing(8) - - briefing_header = QHBoxLayout() - briefing_header.setContentsMargins(0, 0, 0, 0) - briefing_title = QLabel("Release Review") - briefing_title.setObjectName("qualityGateSectionTitle") - briefing_header.addWidget(briefing_title) - briefing_header.addStretch() - self.branch_label = QLabel("Current Branch") - self.branch_label.setObjectName("qualityGateMetricBadge") - briefing_header.addWidget(self.branch_label) - briefing_layout.addLayout(briefing_header) - - goal_label = QLabel("Target Outcome") - goal_label.setObjectName("qualityGateFieldLabel") - briefing_layout.addWidget(goal_label) - self.goal_input = QTextEdit() - self.goal_input.setPlaceholderText("What should this branch be able to do when it is truly ready?") - self.goal_input.setFixedHeight(90) - self.goal_input.setStyleSheet("QTextEdit { font-size: 12px; }" + QUALITY_GATE_SCROLLBAR_STYLE) - self.goal_input.textChanged.connect(self._on_goal_changed) - briefing_layout.addWidget(self.goal_input) - - criteria_label = QLabel("Acceptance Criteria / Shipping Bar") - criteria_label.setObjectName("qualityGateFieldLabel") - briefing_layout.addWidget(criteria_label) - self.criteria_input = QTextEdit() - self.criteria_input.setPlaceholderText("What must be proven before this should be considered ready? Think tests, UX, correctness, evidence, polish, or constraints.") - self.criteria_input.setFixedHeight(78) - self.criteria_input.setStyleSheet("QTextEdit { font-size: 12px; }" + QUALITY_GATE_SCROLLBAR_STYLE) - self.criteria_input.textChanged.connect(self._on_criteria_changed) - briefing_layout.addWidget(self.criteria_input) - - controls_panel = QWidget() - controls_layout = QHBoxLayout(controls_panel) - controls_layout.setContentsMargins(0, 4, 0, 0) - controls_layout.setSpacing(10) - - self.run_button = QPushButton("Run Quality Gate") - self.run_button.setIcon(qta.icon("fa5s.check-circle", color=button_text_color)) - self.run_button.clicked.connect(lambda: self.review_requested.emit(self)) - controls_layout.addWidget(self.run_button) - - self.note_button = QPushButton("Create Summary Note") - self.note_button.setIcon(qta.icon("fa5s.sticky-note", color=button_text_color)) - self.note_button.clicked.connect(lambda: self.note_requested.emit(self)) - self.note_button.setEnabled(False) - controls_layout.addWidget(self.note_button) - - self.status_label = QLabel("Idle") - self.status_label.setObjectName("qualityGateMetricBadge") - controls_layout.addWidget(self.status_label) - controls_layout.addStretch() - status_hint = QLabel("Best used after a branch has meaningful implementation, research, or drafting work to inspect.") - status_hint.setObjectName("qualityGateSectionHint") - status_hint.setWordWrap(True) - controls_layout.addWidget(status_hint, 1) - briefing_layout.addWidget(controls_panel) - - main_layout.addWidget(briefing_surface) - - metrics_bar = QHBoxLayout() - metrics_bar.setContentsMargins(2, 0, 2, 0) - metrics_bar.setSpacing(8) - - self.verdict_label = QLabel("Verdict: Pending") - self.verdict_label.setObjectName("qualityGateMetricBadge") - metrics_bar.addWidget(self.verdict_label) - - self.score_label = QLabel("Readiness: --") - self.score_label.setObjectName("qualityGateMetricBadge") - metrics_bar.addWidget(self.score_label) - - self.fix_count_label = QLabel("Fix Paths: 0") - self.fix_count_label.setObjectName("qualityGateMetricBadge") - metrics_bar.addWidget(self.fix_count_label) - metrics_bar.addStretch() - main_layout.addLayout(metrics_bar) - - self.workspace_tabs = QTabWidget() - self.workspace_tabs.setDocumentMode(True) - - review_panel = QWidget() - review_layout = QVBoxLayout(review_panel) - review_layout.setContentsMargins(0, 0, 0, 0) - review_layout.setSpacing(8) - - review_subtitle = QLabel("This report focuses on readiness, blockers, missing evidence, and the shortest credible path to ship.") - review_subtitle.setObjectName("qualityGateSectionHint") - review_subtitle.setWordWrap(True) - review_layout.addWidget(review_subtitle) - - self.review_display = QTextEdit() - self.review_display.setReadOnly(True) - self.review_display.setPlaceholderText("Run Quality Gate to generate a rich production-readiness review for this branch.") - self.review_display.document().setDefaultStyleSheet(""" - h1, h2 { color: #ffffff; } - p, li { color: #d6d6d6; font-family: 'Segoe UI', sans-serif; font-size: 12px; } - code { background-color: #31353b; padding: 2px 4px; border-radius: 4px; color: #ffe07d; } - """) - self.review_display.setStyleSheet(""" - QTextEdit { - font-size: 12px; - background-color: transparent; - border: none; - padding: 6px 2px 2px 2px; - } - """ + QUALITY_GATE_SCROLLBAR_STYLE) - review_layout.addWidget(self.review_display, stretch=1) - - fix_panel = QWidget() - fix_layout = QVBoxLayout(fix_panel) - fix_layout.setContentsMargins(0, 0, 0, 0) - fix_layout.setSpacing(8) - - fix_header = QHBoxLayout() - fix_header.setContentsMargins(0, 0, 0, 0) - fix_title = QLabel("Launch the highest-value remediation or validation nodes directly from this list.") - fix_title.setObjectName("qualityGateSectionHint") - fix_title.setWordWrap(True) - fix_header.addWidget(fix_title, 1) - fix_header.addStretch() - self.fix_count_header_label = QLabel("0") - self.fix_count_header_label.setObjectName("qualityGateMetricBadge") - fix_header.addWidget(self.fix_count_header_label) - fix_layout.addLayout(fix_header) - - self.recommendation_scroll = QScrollArea() - self.recommendation_scroll.setWidgetResizable(True) - self.recommendation_scroll.setFrameShape(QFrame.Shape.NoFrame) - self.recommendation_scroll.setStyleSheet(""" - QScrollArea { - background-color: transparent; - border: none; - } - QScrollArea > QWidget > QWidget { - background: transparent; - } - """ + QUALITY_GATE_SCROLLBAR_STYLE) - - self.recommendation_content = QWidget() - self.recommendation_content.setStyleSheet("background: transparent;") - self.recommendation_layout = QVBoxLayout(self.recommendation_content) - self.recommendation_layout.setContentsMargins(0, 6, 0, 0) - self.recommendation_layout.setSpacing(8) - self.recommendation_layout.addWidget(self._build_empty_state()) - self.recommendation_layout.addStretch() - self.recommendation_scroll.setWidget(self.recommendation_content) - fix_layout.addWidget(self.recommendation_scroll) - - self.workspace_tabs.addTab(review_panel, qta.icon("fa5s.clipboard-list", color="#cccccc"), "Review") - self.workspace_tabs.addTab(fix_panel, qta.icon("fa5s.tools", color="#cccccc"), "Fix Paths (0)") - main_layout.addWidget(self.workspace_tabs, stretch=1) - - button_style = f""" - QPushButton {{ - background-color: {node_color.name()}; - color: {button_text_color}; - border: none; - border-radius: 8px; - padding: 9px 16px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: {node_color.lighter(108).name()}; - }} - QPushButton:disabled {{ - background-color: #555555; - color: #cccccc; - }} - """ - self.run_button.setStyleSheet(button_style) - self.note_button.setStyleSheet(button_style) - - self.refresh_branch_context() - self._apply_verdict_badge("pending") - self.set_status("Idle") - - def _build_empty_state(self): - label = QLabel("No fix paths yet. Run Quality Gate to generate a readiness review and the highest-value follow-up nodes.") - label.setWordWrap(True) - label.setStyleSheet("color: #8b929b; padding: 12px; background-color: #171a1d; border: 1px dashed #353b42; border-radius: 8px;") - return label - - def _clear_recommendations(self): - while self.recommendation_layout.count(): - item = self.recommendation_layout.takeAt(0) - widget = item.widget() - if widget is not None: - widget.deleteLater() - - def _on_goal_changed(self): - self.goal = self.goal_input.toPlainText() - - def _on_criteria_changed(self): - self.criteria = self.criteria_input.toPlainText() - - def get_goal(self): - return self.goal_input.toPlainText() - - def seed_prompt(self, text): - """Protocol method used by graphite_window_actions.instantiate_seeded_plugin.""" - self.goal_input.setPlainText(text) - - def get_criteria(self): - return self.criteria_input.toPlainText() - - def get_review_payload(self): - include_branch_context = bool(getattr(self, "include_branch_context", True)) - source_node = self.parent_node if include_branch_context and self.parent_node else self - return build_quality_gate_payload(source_node, include_branch_context=include_branch_context) - - def refresh_branch_context(self): - payload = self.get_review_payload() - context_state = "On" if bool(getattr(self, "include_branch_context", True)) else "Off" - label = f"{payload.get('label', 'Current Branch')} - Depth {payload.get('depth', 0)} - Context {context_state}" - self.branch_label.setText(label) - self.branch_label.setToolTip("\n".join(payload.get("node_labels", [])) or payload.get("label", "Current Branch")) - - def _apply_verdict_badge(self, verdict): - verdict = (verdict or "pending").lower() - if verdict == "ready": - color = get_semantic_color("status_success") - text = "Verdict: Ready" - elif verdict == "blocked": - color = get_semantic_color("status_error") - text = "Verdict: Blocked" - elif verdict == "needs_work": - color = get_semantic_color("status_warning") - text = "Verdict: Needs Work" - else: - color = QColor("#9aa3ad") - text = "Verdict: Pending" - - self.verdict_label.setText(text) - self.verdict_label.setStyleSheet( - f"color: {color.name()}; background-color: rgba({color.red()}, {color.green()}, {color.blue()}, 0.1); " - f"border: 1px solid rgba({color.red()}, {color.green()}, {color.blue()}, 0.22); border-radius: 10px; " - "padding: 3px 8px; font-size: 11px; font-weight: bold;" - ) - - def _apply_score_badge(self): - color = get_semantic_color("status_success") if self.readiness_score >= 85 else get_semantic_color("status_warning") - if self.readiness_score < 55: - color = get_semantic_color("status_error") - self.score_label.setText(f"Readiness: {self.readiness_score}/100" if self.readiness_score else "Readiness: --") - self.score_label.setStyleSheet( - f"color: {color.name()}; background-color: rgba({color.red()}, {color.green()}, {color.blue()}, 0.1); " - f"border: 1px solid rgba({color.red()}, {color.green()}, {color.blue()}, 0.22); border-radius: 10px; " - "padding: 3px 8px; font-size: 11px; font-weight: bold;" - ) - - def _apply_fix_count_badge(self): - color = QColor(get_current_palette().FRAME_COLORS["Yellow"]["color"]) - self.fix_count_label.setText(f"Fix Paths: {len(self.recommendations)}") - self.fix_count_header_label.setText(str(len(self.recommendations))) - style = ( - f"color: {color.name()}; background-color: rgba({color.red()}, {color.green()}, {color.blue()}, 0.12); " - f"border: 1px solid rgba({color.red()}, {color.green()}, {color.blue()}, 0.24); border-radius: 10px; " - "padding: 3px 8px; font-size: 11px; font-weight: bold;" - ) - self.fix_count_label.setStyleSheet(style) - self.fix_count_header_label.setStyleSheet(style) - - def set_running_state(self, is_running): - if self.is_disposed: - return - - self.run_button.setEnabled(not is_running) - self.note_button.setEnabled(bool(self.note_summary.strip()) and not is_running) - self.goal_input.setReadOnly(is_running) - self.criteria_input.setReadOnly(is_running) - self.run_button.setText("Reviewing..." if is_running else "Run Quality Gate") - - if is_running: - self.set_status("Reviewing production readiness...") - elif self.status.startswith("Error") or self.status in {"Ready to Ship", "Needs Work", "Blocked"}: - return - elif self.review_markdown.strip(): - self.set_status("Completed") - else: - self.set_status("Idle") - - def set_status(self, status_text): - if self.is_disposed: - return - - self.status = status_text - self.status_label.setText(status_text) - if "Reviewing" in status_text: - color = get_semantic_color("status_info") - elif "Ready" in status_text: - color = get_semantic_color("status_success") - elif "Needs Work" in status_text: - color = get_semantic_color("status_warning") - elif "Blocked" in status_text or "Error" in status_text: - color = get_semantic_color("status_error") - elif "Completed" in status_text: - color = get_semantic_color("status_warning") - else: - color = QColor("#9aa3ad") - - self.status_label.setStyleSheet( - f"color: {color.name()}; background-color: rgba({color.red()}, {color.green()}, {color.blue()}, 0.1); " - f"border: 1px solid rgba({color.red()}, {color.green()}, {color.blue()}, 0.22); border-radius: 10px; " - "padding: 3px 8px; font-size: 11px; font-weight: bold;" - ) - - def set_review(self, review): - if self.is_disposed: - return - - self.verdict = review.get("verdict", "needs_work") - try: - self.readiness_score = int(review.get("readiness_score", 0) or 0) - except (TypeError, ValueError): - self.readiness_score = 0 - self.review_markdown = review.get("review_markdown", "") - self.note_summary = review.get("note_summary", "") - self.recommendations = review.get("recommended_plugins", []) - - self.review_display.setMarkdown(self.review_markdown) - self.workspace_tabs.setTabText(1, f"Fix Paths ({len(self.recommendations)})") - self.note_button.setEnabled(bool(self.note_summary.strip())) - - self._apply_verdict_badge(self.verdict) - self._apply_score_badge() - self._apply_fix_count_badge() - - palette = get_current_palette() - accent_color = palette.FRAME_COLORS["Yellow"]["color"] - self._clear_recommendations() - - if not self.recommendations: - self.recommendation_layout.addWidget(self._build_empty_state()) - else: - for recommendation in self.recommendations: - card = QualityGateRecommendationCard(recommendation, accent_color) - card.add_requested.connect(lambda plugin, prompt, node=self: self.plugin_requested.emit(node, plugin, prompt)) - self.recommendation_layout.addWidget(card) - self.recommendation_layout.addStretch() - - if self.verdict == "ready": - self.set_status("Ready to Ship") - elif self.verdict == "blocked": - self.set_status("Blocked") - else: - self.set_status("Needs Work") - - def set_error(self, error_message): - if self.is_disposed: - return - - self.review_markdown = f"## Error\n\n{error_message}" - self.note_summary = "" - self.recommendations = [] - self.readiness_score = 0 - self.review_display.setMarkdown(self.review_markdown) - self.note_button.setEnabled(False) - self._apply_verdict_badge("blocked") - self._apply_score_badge() - self._apply_fix_count_badge() - self.workspace_tabs.setTabText(1, "Fix Paths (0)") - self._clear_recommendations() - self.recommendation_layout.addWidget(self._build_empty_state()) - self.recommendation_layout.addStretch() - self.set_status(f"Error: {error_message}") - - def dispose(self): - if self.is_disposed: - return - - self.is_disposed = True - - worker_thread = self.worker_thread - self.worker_thread = None - if worker_thread: - try: - worker_thread.stop() - except Exception: - pass - for signal in (worker_thread.finished, worker_thread.error): - try: - signal.disconnect() - except (TypeError, RuntimeError): - pass - try: - if worker_thread.isRunning(): - worker_thread.finished.connect(worker_thread.deleteLater) - else: - worker_thread.deleteLater() - except RuntimeError: - pass - - proxy = getattr(self, "proxy", None) - if proxy is not None: - try: - embedded_widget = proxy.widget() - except RuntimeError: - embedded_widget = None - try: - proxy.setWidget(None) - except RuntimeError: - pass - if embedded_widget is not None: - try: - embedded_widget.hide() - embedded_widget.deleteLater() - except RuntimeError: - pass - - def boundingRect(self): - padding = self.CONNECTION_DOT_OFFSET + self.CONNECTION_DOT_RADIUS - return QRectF(-padding, 0, self.width + 2 * padding, self.height) - - def paint(self, painter, option, widget=None): - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Yellow"]["color"]) - - path = QPainterPath() - path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2d2d2d")) - - pen = QPen(node_color, 1.5) - if self.isSelected(): - pen = QPen(palette.SELECTION, 2) - elif self.is_search_match: - pen = QPen(get_semantic_color("search_highlight"), 2) - elif self.hovered: - pen = QPen(QColor("#ffffff"), 2) - - painter.setPen(pen) - painter.drawPath(path) - - dot_color = node_color if not (self.isSelected() or self.hovered) else pen.color().lighter(110) - painter.setBrush(dot_color) - painter.setPen(Qt.PenStyle.NoPen) - - dot_rect_left = QRectF(-self.CONNECTION_DOT_RADIUS, (self.height / 2) - self.CONNECTION_DOT_RADIUS, self.CONNECTION_DOT_RADIUS * 2, self.CONNECTION_DOT_RADIUS * 2) - painter.drawPie(dot_rect_left, 90 * 16, -180 * 16) - - dot_rect_right = QRectF(self.width - self.CONNECTION_DOT_RADIUS, (self.height / 2) - self.CONNECTION_DOT_RADIUS, self.CONNECTION_DOT_RADIUS * 2, self.CONNECTION_DOT_RADIUS * 2) - painter.drawPie(dot_rect_right, 90 * 16, 180 * 16) - - if self.is_collapsed: - painter.setPen(QColor("#ffffff")) - painter.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold)) - painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "Quality Gate") - qta.icon("fa5s.check-circle", color=node_color.name()).paint(painter, QRect(10, 10, 20, 20)) - self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - qta.icon("fa5s.expand-arrows-alt", color="#ffffff" if self.hovered else "#888888").paint(painter, QRect(int(self.width - 30), 10, 20, 20)) - else: - if self.hovered: - self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - painter.setBrush(QColor(255, 255, 255, 30)) - painter.setPen(QColor(255, 255, 255, 150)) - painter.drawRoundedRect(self.collapse_button_rect.adjusted(6, 6, -6, -6), 4, 4) - painter.setPen(QPen(QColor("#ffffff"), 2)) - center = self.collapse_button_rect.center() - painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) - else: - self.collapse_button_rect = QRectF() - - def mousePressEvent(self, event): - if self.collapse_button_rect.contains(event.pos()): - self.toggle_collapse() - event.accept() - return - - if event.button() == Qt.MouseButton.LeftButton and self.scene(): - self.scene().is_dragging_item = True - if hasattr(self.scene(), "window"): - self.scene().window.setCurrentNode(self) - super().mousePressEvent(event) - - def contextMenuEvent(self, event): - menu = PluginNodeContextMenu(self) - menu.exec(event.screenPos()) - - def mouseReleaseEvent(self, event): - if self.scene(): - self.scene().is_dragging_item = False - self.scene()._clear_smart_guides() - super().mouseReleaseEvent(event) - - def itemChange(self, change, value): - if change == QGraphicsObject.GraphicsItemChange.ItemPositionChange and self.scene() and self.scene().is_dragging_item: - return self.scene().snap_position(self, value) - if change == QGraphicsObject.GraphicsItemChange.ItemPositionHasChanged and self.scene(): - self.scene().nodeMoved(self) - return super().itemChange(change, value) - - def hoverEnterEvent(self, event): - self._handle_hover_enter(event) - super().hoverEnterEvent(event) - - def hoverLeaveEvent(self, event): - self._handle_hover_leave(event) - super().hoverLeaveEvent(event) diff --git a/graphite_app/graphite_plugins/graphite_plugin_reasoning.py b/graphite_app/graphite_plugins/graphite_plugin_reasoning.py deleted file mode 100644 index d08e1a4..0000000 --- a/graphite_app/graphite_plugins/graphite_plugin_reasoning.py +++ /dev/null @@ -1,522 +0,0 @@ -from PySide6.QtWidgets import ( - QGraphicsObject, QGraphicsProxyWidget, QWidget, QVBoxLayout, - QTextEdit, QPushButton, QLabel, QHBoxLayout, QSlider, QProgressBar -) -from PySide6.QtCore import QRectF, Qt, Signal, QRect, QThread -from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QPainterPath, QFont -import qtawesome as qta -from graphite_config import get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color -from graphite_canvas_items import HoverAnimationMixin -from graphite_connections import ConnectionItem -from graphite_lod import draw_lod_card, preview_text, sync_proxy_render_state -from graphite_memory import append_history, get_node_history -from graphite_plugins.graphite_plugin_context_menu import PluginNodeContextMenu -from graphite_plugins.reasoning.agent import ReasoningAgent - - -class ReasoningWorkerThread(QThread): - """Orchestrates the ReasoningAgent's plan -> (reason+critique) x budget -> synthesize - workflow in a background thread. - - Cancellation is cooperative (an _is_running flag checked between calls), same - limitation as every other threaded plugin in this codebase (Artifact, Workflow, - etc.) - api_provider.chat() is always a single blocking call, so a .stop() cannot - interrupt a call already in flight, only prevent the next one from starting. - """ - - step_finished = Signal(str, str) # title, text - finished = Signal(str) # final_answer - error = Signal(str) - - def __init__(self, agent: ReasoningAgent, original_prompt: str, budget: int, branch_context: str = ""): - super().__init__() - self.agent = agent - self.original_prompt = original_prompt - self.budget = budget - self.branch_context = branch_context or "No prior branch context." - self._is_running = True - - def _format_plan_text(self, steps): - return "\n".join(f"{i}. {step['title']}: {step['goal']}" for i, step in enumerate(steps, start=1)) - - def _format_step_text(self, step_result): - return ( - f"**Initial Thought:**\n{step_result['initial_thought']}\n\n" - f"**Critique:**\n{step_result['critique']}\n\n" - f"**Refined Thought:**\n{step_result['refined_thought']}" - ) - - def run(self): - try: - if not self._is_running: - return - - plan_result = self.agent.plan(self.original_prompt, self.branch_context) - steps = plan_result["steps"] - self.step_finished.emit("Step 1: The Plan", self._format_plan_text(steps)) - - thought_history = [] - - for i in range(self.budget): - if not self._is_running: - return - - step = steps[i % len(steps)] - step_result = self.agent.reason_and_critique( - self.original_prompt, self.branch_context, step, thought_history - ) - - if not self._is_running: - return - - thought_history.append(f"Thought on '{step['title']}':\n{step_result['refined_thought']}") - self.step_finished.emit(f"Step {i + 2}: {step['title']}", self._format_step_text(step_result)) - - if not self._is_running: - return - - final_answer = self.agent.synthesize(self.original_prompt, self.branch_context, thought_history) - - if self._is_running: - self.finished.emit(final_answer) - - except Exception as e: - if self._is_running: - self.error.emit(str(e)) - finally: - self._is_running = False - - def stop(self): - self._is_running = False - - -class ReasoningConnectionItem(ConnectionItem): - """A visually distinct connection for ReasoningNode, featuring a blue dash-dot-dot line.""" - - def paint(self, painter, option, widget=None): - if not (self.start_node and self.end_node): - return - - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Blue"]["color"]) - - pen = QPen(node_color, 2, Qt.PenStyle.DashDotDotLine) - pen.setCapStyle(Qt.PenCapStyle.RoundCap) - - if self.hover: - pen.setWidth(3) - - painter.setPen(pen) - painter.drawPath(self.path) - - if self.is_animating: - for arrow in self.arrows: - self.drawArrow(painter, arrow['pos'], node_color) - - def drawArrow(self, painter, pos, color): - if pos < 0 or pos > 1: - return - point = self.path.pointAtPercent(pos) - angle = self.path.angleAtPercent(pos) - - arrow = QPainterPath() - arrow.moveTo(-self.arrow_size, -self.arrow_size / 2) - arrow.lineTo(0, 0) - arrow.lineTo(-self.arrow_size, self.arrow_size / 2) - - painter.save() - painter.translate(point) - painter.rotate(-angle) - painter.setBrush(color) - painter.setPen(Qt.PenStyle.NoPen) - painter.drawPath(arrow) - painter.restore() - - -class ReasoningNode(QGraphicsObject, HoverAnimationMixin): - """ - A specialized QGraphicsItem that provides a UI for a multi-step, iterative - reasoning process to solve complex problems. - """ - supports_branch_context_toggle = True - - reasoning_requested = Signal(object) - stop_requested = Signal(object) - - NODE_WIDTH = 550 - NODE_HEIGHT = 700 - COLLAPSED_WIDTH = 250 - COLLAPSED_HEIGHT = 40 - CONNECTION_DOT_RADIUS = 5 - CONNECTION_DOT_OFFSET = 0 - - def __init__(self, parent_node, parent=None): - super().__init__(parent) - HoverAnimationMixin.__init__(self) - self.parent_node = parent_node - self.children = [] - self.is_user = False - self.conversation_history = [] - - self.is_collapsed = False - self.collapse_button_rect = QRectF() - - self.prompt = "" - self.thinking_budget = 3 - self.status = "Idle" - self.thought_process = "" - self.worker_thread = None - self.is_running = False - - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemIsMovable) - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemIsSelectable) - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemSendsGeometryChanges) - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemUsesExtendedStyleOption) - self.setAcceptHoverEvents(True) - self.hovered = False - self._render_lod_mode = "full" - - self.widget = QWidget() - self.widget.setObjectName("reasoningMainWidget") - self.widget.setFixedSize(self.NODE_WIDTH, self.NODE_HEIGHT) - self.widget.setStyleSheet(""" - QWidget#reasoningMainWidget { background-color: transparent; color: #e0e0e0; } - QWidget#reasoningMainWidget QLabel { background-color: transparent; } - """) - - self._setup_ui() - - self.proxy = QGraphicsProxyWidget(self) - self.proxy.setWidget(self.widget) - - @property - def width(self): - return self.COLLAPSED_WIDTH if self.is_collapsed else self.NODE_WIDTH - - @property - def height(self): - return self.COLLAPSED_HEIGHT if self.is_collapsed else self.NODE_HEIGHT - - def set_collapsed(self, collapsed): - if self.is_collapsed != collapsed: - self.is_collapsed = collapsed - self.proxy.setVisible(not self.is_collapsed and self._render_lod_mode == "full") - self.prepareGeometryChange() - if self.scene(): - self.sync_view_lod() - self.scene().update_connections() - self.scene().nodeMoved(self) - self.update() - - def toggle_collapse(self): - self.set_collapsed(not self.is_collapsed) - - def sync_view_lod(self, view_rect=None, zoom=None): - sync_proxy_render_state(self, view_rect, zoom) - if not self.is_collapsed: - self.update() - - def _setup_ui(self): - main_layout = QVBoxLayout(self.widget) - main_layout.setContentsMargins(15, 15, 15, 15) - main_layout.setSpacing(10) - - node_colors = get_graph_node_colors() - node_color = node_colors["header"] - - header_layout = QHBoxLayout() - icon = QLabel() - icon.setPixmap(qta.icon('fa5s.brain', color=node_color).pixmap(18, 18)) - header_layout.addWidget(icon) - title_label = QLabel("Graphlink-Reasoning") - title_label.setStyleSheet(f"font-weight: bold; font-size: 14px; color: {node_color.name()}; background: transparent;") - header_layout.addWidget(title_label) - header_layout.addStretch() - main_layout.addLayout(header_layout) - - main_layout.addWidget(QLabel("Complex Prompt:")) - self.prompt_input = QTextEdit() - self.prompt_input.setPlaceholderText("Enter a complex problem or question that requires deep reasoning...") - self.prompt_input.setFixedHeight(100) - self.prompt_input.textChanged.connect(self._on_prompt_changed) - main_layout.addWidget(self.prompt_input) - - budget_layout = QHBoxLayout() - budget_layout.addWidget(QLabel("Thinking Budget:")) - self.budget_slider = QSlider(Qt.Orientation.Horizontal) - self.budget_slider.setMinimum(1) - self.budget_slider.setMaximum(10) - self.budget_slider.setValue(self.thinking_budget) - self.budget_slider.setTickInterval(1) - self.budget_slider.setTickPosition(QSlider.TickPosition.TicksBelow) - self.budget_slider.valueChanged.connect(self._on_budget_changed) - budget_layout.addWidget(self.budget_slider) - self.budget_label = QLabel(str(self.thinking_budget)) - self.budget_label.setFixedWidth(25) - self.budget_label.setStyleSheet("background: transparent;") - budget_layout.addWidget(self.budget_label) - main_layout.addLayout(budget_layout) - - self.run_button = QPushButton("Start Reasoning") - self.run_button.clicked.connect(self._handle_action_button) - main_layout.addWidget(self.run_button) - - self.status_label = QLabel("Status: Idle") - self.status_label.setStyleSheet("color: #888; font-style: italic; background: transparent;") - main_layout.addWidget(self.status_label) - - # Indeterminate ("busy") progress bar - the only prior feedback while a run was - # in progress was this same status_label's text changing to "Thinking...", which - # is easy to miss on a canvas full of nodes. This gives an actually-visible, - # continuously-animating signal that something is happening in the background. - self.progress_bar = QProgressBar() - self.progress_bar.setRange(0, 0) - self.progress_bar.setTextVisible(False) - self.progress_bar.setFixedHeight(6) - self.progress_bar.setStyleSheet(""" - QProgressBar { - background-color: #2b2b2b; - border: none; - border-radius: 3px; - } - QProgressBar::chunk { - background-color: #4a90d9; - border-radius: 3px; - } - """) - self.progress_bar.setVisible(False) - main_layout.addWidget(self.progress_bar) - - main_layout.addWidget(QLabel("Thought Process:")) - self.thought_process_display = QTextEdit() - self.thought_process_display.setReadOnly(True) - self.thought_process_display.setPlaceholderText("The AI's step-by-step reasoning will appear here...") - main_layout.addWidget(self.thought_process_display) - - for widget in [self.prompt_input, self.thought_process_display]: - widget.setStyleSheet(""" - QTextEdit { - background-color: #252526; border: 1px solid #3f3f3f; - color: #cccccc; border-radius: 4px; padding: 5px; - font-family: Segoe UI, sans-serif; - } - """) - - button_colors = get_neutral_button_colors() - - self.run_button.setIcon(qta.icon('fa5s.cogs', color=button_colors["icon"].name())) - self.run_button.setStyleSheet(f""" - QPushButton {{ - background-color: {button_colors["background"].name()}; - color: {button_colors["icon"].name()}; - border: 1px solid {button_colors["border"].name()}; - border-radius: 4px; - padding: 8px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: {button_colors["hover"].name()}; - border-color: {button_colors["hover"].lighter(112).name()}; - }} - QPushButton:pressed {{ - background-color: {button_colors["pressed"].name()}; - border-color: {button_colors["border"].darker(105).name()}; - }} - """) - - def _on_prompt_changed(self): - self.prompt = self.prompt_input.toPlainText() - - def seed_prompt(self, text): - """Protocol method used by graphite_window_actions.instantiate_seeded_plugin.""" - self.prompt_input.setPlainText(text) - self._on_prompt_changed() - - def _on_budget_changed(self, value): - self.thinking_budget = value - self.budget_label.setText(str(value)) - - def _handle_action_button(self): - # Single dual-purpose button (mirrors ArtifactNode._handle_action_button): while - # a run is in progress the button switches to "Stop Reasoning" - and stays - # enabled the whole time, unlike the prior implementation which disabled it, - # making the button unreachable exactly when a cancel was most wanted. - if self.is_running: - self.stop_requested.emit(self) - return - self.reasoning_requested.emit(self) - - def set_running_state(self, is_running: bool): - self.is_running = is_running - self.run_button.setEnabled(True) - self.prompt_input.setReadOnly(is_running) - self.budget_slider.setEnabled(not is_running) - self.run_button.setText("Stop Reasoning" if is_running else "Start Reasoning") - self.progress_bar.setVisible(is_running) - if is_running: - self.set_status("Thinking...") - else: - self.set_status("Completed") - - def set_status(self, status_text: str): - self.status = status_text - self.status_label.setText(f"Status: {status_text}") - if "Thinking" in status_text or "Step" in status_text: - self.status_label.setStyleSheet(f"color: {get_semantic_color('status_info').name()}; background: transparent;") - elif "Completed" in status_text: - self.status_label.setStyleSheet(f"color: {get_semantic_color('status_success').name()}; background: transparent;") - else: - self.status_label.setStyleSheet("color: #888; background: transparent;") - - def clear_thoughts(self): - self.thought_process = "" - self.thought_process_display.clear() - - def append_thought(self, step_title: str, thought_text: str): - formatted_step = f"## {step_title}\n\n{thought_text}\n\n---\n\n" - self.thought_process += formatted_step - self.thought_process_display.setMarkdown(self.thought_process) - scrollbar = self.thought_process_display.verticalScrollBar() - scrollbar.setValue(scrollbar.maximum()) - - def set_final_answer(self, final_text: str, parent_history=None): - base_history = parent_history if parent_history is not None else get_node_history(self.parent_node) - prompt_text = self.prompt.strip() if self.prompt.strip() else "[Reasoning Prompt]" - self.conversation_history = append_history(base_history, [ - {'role': 'user', 'content': prompt_text}, - {'role': 'assistant', 'content': final_text} - ]) - self.append_thought("Final Answer", final_text) - self.set_status("Completed") - - def set_error(self, error_message: str): - self.status = f"Error: {error_message}" - self.status_label.setText(self.status) - self.status_label.setStyleSheet(f"color: {get_semantic_color('status_error').name()}; font-weight: bold; background: transparent;") - self.append_thought("Error", f"An error occurred during the process:\n\n{error_message}") - self.set_running_state(False) - - def boundingRect(self): - padding = self.CONNECTION_DOT_OFFSET + self.CONNECTION_DOT_RADIUS - return QRectF(-padding, 0, self.width + 2 * padding, self.height) - - def paint(self, painter, option, widget=None): - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - palette = get_current_palette() - node_colors = get_graph_node_colors() - render_mode = getattr(self, "_render_lod_mode", "full") - - path = QPainterPath() - path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2d2d2d")) - - node_color = node_colors["border"] - pen = QPen(node_color, 1.5) - - if self.isSelected(): - pen = QPen(palette.SELECTION, 2) - elif self.hovered: - pen = QPen(QColor("#ffffff"), 2) - - painter.setPen(pen) - painter.drawPath(path) - - dot_color = node_colors["dot"] - if self.isSelected() or self.hovered: - dot_color = pen.color().lighter(110) if self.isSelected() else node_colors["hover_dot"] - - painter.setBrush(dot_color) - painter.setPen(Qt.PenStyle.NoPen) - - dot_rect_left = QRectF(-self.CONNECTION_DOT_RADIUS, (self.height / 2) - self.CONNECTION_DOT_RADIUS, self.CONNECTION_DOT_RADIUS * 2, self.CONNECTION_DOT_RADIUS * 2) - painter.drawPie(dot_rect_left, 90 * 16, -180 * 16) - - dot_rect_right = QRectF(self.width - self.CONNECTION_DOT_RADIUS, (self.height / 2) - self.CONNECTION_DOT_RADIUS, self.CONNECTION_DOT_RADIUS * 2, self.CONNECTION_DOT_RADIUS * 2) - painter.drawPie(dot_rect_right, 90 * 16, 180 * 16) - - if not self.is_collapsed and render_mode != "full": - self.collapse_button_rect = QRectF() - draw_lod_card( - painter, - QRectF(0, 0, self.width, self.height), - accent=node_color, - selection_color=palette.SELECTION, - title="Graphlink-Reasoning", - subtitle=self.status or "Idle", - preview=preview_text(self.prompt, self.thought_process, fallback="Reason through a problem"), - badge="THINK", - mode=render_mode, - selected=self.isSelected(), - hovered=self.hovered, - connection_radius=self.CONNECTION_DOT_RADIUS, - ) - return - - if self.is_collapsed: - painter.setPen(QColor("#ffffff")) - font = QFont("Segoe UI", 10, QFont.Weight.Bold) - painter.setFont(font) - painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "Graphlink-Reasoning") - - icon = qta.icon('fa5s.brain', color=node_color.name()) - icon.paint(painter, QRect(10, 10, 20, 20)) - - self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - expand_icon = qta.icon('fa5s.expand-arrows-alt', color='#ffffff' if self.hovered else '#888888') - expand_icon.paint(painter, QRect(int(self.width - 30), 10, 20, 20)) - else: - if self.hovered: - self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - painter.setBrush(QColor(255, 255, 255, 30)) - painter.setPen(QColor(255, 255, 255, 150)) - painter.drawRoundedRect(self.collapse_button_rect.adjusted(6, 6, -6, -6), 4, 4) - - icon_pen = QPen(QColor("#ffffff"), 2) - painter.setPen(icon_pen) - center = self.collapse_button_rect.center() - painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) - else: - self.collapse_button_rect = QRectF() - - def mousePressEvent(self, event): - if self.is_collapsed and event.button() == Qt.MouseButton.LeftButton: - self.toggle_collapse() - event.accept() - return - - if self.collapse_button_rect.contains(event.pos()): - self.toggle_collapse() - event.accept() - return - - if event.button() == Qt.MouseButton.LeftButton and self.scene(): - self.scene().is_dragging_item = True - if hasattr(self.scene(), 'window'): - self.scene().window.setCurrentNode(self) - super().mousePressEvent(event) - - def contextMenuEvent(self, event): - menu = PluginNodeContextMenu(self) - menu.exec(event.screenPos()) - - def mouseReleaseEvent(self, event): - if self.scene(): - self.scene().is_dragging_item = False - self.scene()._clear_smart_guides() - super().mouseReleaseEvent(event) - - def itemChange(self, change, value): - if change == QGraphicsObject.GraphicsItemChange.ItemPositionChange and self.scene() and self.scene().is_dragging_item: - return self.scene().snap_position(self, value) - if change == QGraphicsObject.GraphicsItemChange.ItemPositionHasChanged and self.scene(): - self.scene().nodeMoved(self) - return super().itemChange(change, value) - - def hoverEnterEvent(self, event): - self._handle_hover_enter(event) - super().hoverEnterEvent(event) - - def hoverLeaveEvent(self, event): - self._handle_hover_leave(event) - super().hoverLeaveEvent(event) diff --git a/graphite_app/graphite_plugins/graphite_plugin_workflow.py b/graphite_app/graphite_plugins/graphite_plugin_workflow.py deleted file mode 100644 index 91e5cdf..0000000 --- a/graphite_app/graphite_plugins/graphite_plugin_workflow.py +++ /dev/null @@ -1,1080 +0,0 @@ -from PySide6.QtCore import QRectF, Qt, Signal, QThread, QRect -from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen -from PySide6.QtWidgets import ( - QFrame, - QGraphicsObject, - QGraphicsProxyWidget, - QHBoxLayout, - QLabel, - QPushButton, - QScrollArea, - QTabWidget, - QTextEdit, - QVBoxLayout, - QWidget, -) -import qtawesome as qta - -import graphite_config as config -from graphite_canvas_items import HoverAnimationMixin -from graphite_config import get_current_palette, get_semantic_color -from graphite_connections import ConnectionItem -from graphite_lod import draw_lod_card, preview_text, sync_proxy_render_state -from graphite_plugins.graphite_plugin_context_menu import PluginNodeContextMenu -from graphite_plugins.common.llm_json import call_llm_and_parse_json, extract_json_object - - -WORKFLOW_PLUGIN_ICONS = { - "System Prompt": "fa5s.cog", - "Py-Coder": "fa5s.code", - "Gitlink": "fa5s.link", - "Execution Sandbox": "fa5s.shield-alt", - "Artifact / Drafter": "fa5s.file-alt", - "Graphlink-Web": "fa5s.globe-americas", - "Conversation Node": "fa5s.comments", - "Graphlink-Reasoning": "fa5s.brain", - "HTML Renderer": "fa5s.code", - "Quality Gate": "fa5s.check-circle", - "Code Review Agent": "fa5s.search", -} - -WORKFLOW_ALLOWED_PLUGINS = list(WORKFLOW_PLUGIN_ICONS.keys()) - -# Generated from WORKFLOW_ALLOWED_PLUGINS rather than hand-typed a second time in -# SYSTEM_PROMPT below - the two lists silently drifting out of sync (missing "Code -# Review Agent" from the prompt's bullet list) was a real bug, see -# doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 1.4/4.7. -_WORKFLOW_ALLOWED_PLUGINS_BULLETS = "\n".join(f"- {name}" for name in WORKFLOW_ALLOWED_PLUGINS) - -WORKFLOW_SCROLLBAR_STYLE = """ - QScrollBar:vertical { - background: #1a1d20; - width: 10px; - margin: 0px; - border-radius: 5px; - } - QScrollBar::handle:vertical { - background-color: #555b63; - min-height: 25px; - border-radius: 5px; - } - QScrollBar::handle:vertical:hover { - background-color: #6a727c; - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - height: 0px; - background: none; - } - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { - background: none; - } - QScrollBar:horizontal { - background: #1a1d20; - height: 10px; - margin: 0px; - border-radius: 5px; - } - QScrollBar::handle:horizontal { - background-color: #555b63; - min-width: 25px; - border-radius: 5px; - } - QScrollBar::handle:horizontal:hover { - background-color: #6a727c; - } - QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { - width: 0px; - background: none; - } - QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { - background: none; - } -""" - - -class WorkflowArchitectAgent: - """ - Produces an execution blueprint that decides which existing Graphlink plugins - should be used next for the current task. - """ - - SYSTEM_PROMPT = """ -You are Graphlink's Workflow Architect. -Your job is to examine the user's goal and design the smallest, highest-leverage execution plan using Graphlink's existing tools. - -Allowed plugins: -__ALLOWED_PLUGINS_BULLETS__ - -Rules: -1. Prefer the fewest plugins that will realistically finish the work well. -2. Recommend at most 4 plugins. -3. Only recommend HTML Renderer when the task clearly benefits from rendering HTML or UI output. -4. Only recommend System Prompt when a persistent role/persona change would materially improve the branch. -5. Favor Graphlink-Web for current facts or research, Graphlink-Reasoning for decomposition, Gitlink for repository grounding and codebase context, Py-Coder for lightweight implementation or debugging, Execution Sandbox for dependency-aware Python runs or reproducible package-based experiments, Artifact / Drafter for specs/docs, Conversation Node for deep iterative sub-work, Quality Gate for acceptance review, hardening, or release-readiness checks, and Code Review Agent when a specific local or GitHub file needs a deterministic, scored code-quality review. -6. Output valid JSON only. No markdown fences, no preamble. - -Return exactly this shape: -{ - "title": "Short workflow title", - "mission_brief": "2-4 sentence explanation of the best next move", - "recommended_plugins": [ - { - "plugin": "Plugin Name", - "priority": "high", - "why": "Why this plugin should be used next", - "starter_prompt": "The exact first prompt/query/instruction to seed into that plugin" - } - ], - "workflow_steps": [ - "Step 1 ..." - ], - "deliverables": [ - "Deliverable 1" - ], - "risks": [ - "Risk 1" - ], - "success_signals": [ - "Signal 1" - ] -} -""".replace("__ALLOWED_PLUGINS_BULLETS__", _WORKFLOW_ALLOWED_PLUGINS_BULLETS) - - def _flatten_content(self, content): - if isinstance(content, str): - return content - if isinstance(content, list): - text_parts = [] - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - text_parts.append(part.get("text", "")) - return "\n".join(part for part in text_parts if part) - return str(content) - - def _clean_json_response(self, raw_text): - # Delegates to the shared regex (doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section - # 1.6/3.5) - kept as a wrapper so this method's existing call site is unchanged. - return extract_json_object(raw_text) - - def _fallback_plan(self, goal, constraints, history): - lowered = f"{goal}\n{constraints}".lower() - recommendations = [] - - def add(plugin, why, starter_prompt, priority="high"): - if plugin in WORKFLOW_ALLOWED_PLUGINS and not any(item["plugin"] == plugin for item in recommendations): - recommendations.append({ - "plugin": plugin, - "priority": priority, - "why": why, - "starter_prompt": starter_prompt, - }) - - if any(term in lowered for term in ["latest", "current", "research", "competitor", "market", "news", "trend"]): - add( - "Graphlink-Web", - "The goal depends on external facts or discovery, so web grounding should happen before synthesis.", - goal, - "high", - ) - - if any(term in lowered for term in ["plan", "strategy", "compare", "tradeoff", "architecture", "system", "complex"]): - add( - "Graphlink-Reasoning", - "This work benefits from decomposition, critique, and a structured decision path.", - f"Break this goal into an execution plan with key tradeoffs and checks:\n\n{goal}", - "high", - ) - - if any(term in lowered for term in ["repo", "repository", "github", "git", "codebase", "checkout", "branch", "monorepo"]): - add( - "Gitlink", - "Repository-aware work is easier when the branch is grounded in a concrete codebase snapshot instead of only freeform chat context.", - f"Connect this branch to the relevant repository, load the most useful files or full repo scope, and prepare structured XML context for the follow-up work:\n\n{goal}", - "high", - ) - - if any(term in lowered for term in ["code", "bug", "fix", "build", "implement", "python", "script", "refactor", "ui", "frontend", "html", "css"]): - add( - "Py-Coder", - "Implementation work will move faster once the task is converted into an executable coding prompt.", - f"Implement this carefully and explain the key design decisions:\n\n{goal}", - "high", - ) - - if any(term in lowered for term in ["dependency", "dependencies", "requirements", "virtualenv", "venv", "pandas", "numpy", "matplotlib", "scipy", "sandbox", "library install"]): - add( - "Execution Sandbox", - "This task benefits from an isolated dependency-aware runtime instead of the lighter shared Py-Coder path.", - f"Build and run the required Python workflow inside an isolated sandbox, using only the libraries declared in requirements.txt:\n\n{goal}", - "high", - ) - - if any(term in lowered for term in ["spec", "doc", "draft", "proposal", "write", "report", "summary", "prd", "brief"]): - add( - "Artifact / Drafter", - "A living document will help keep the work product aligned while the branch evolves.", - f"Create or refine the working document for this goal:\n\n{goal}", - "medium", - ) - - if any(term in lowered for term in ["ship", "shipping", "production", "ready", "qa", "quality", "validate", "validation", "acceptance", "hardening", "release"]): - add( - "Quality Gate", - "This goal needs a stronger acceptance review so the branch can be judged against a real shipping bar.", - f"Review this branch for production readiness and identify the highest-value remaining gaps:\n\n{goal}", - "medium", - ) - - if any(term in lowered for term in ["review", "pull request", "pr", "code review", "lint", "audit", "readability", "maintainability", "vulnerab", "security review"]): - add( - "Code Review Agent", - "This goal calls for a deterministic, scored review of a specific file rather than a broad readiness check.", - f"Review the relevant source file for correctness, security, and maintainability, and score it against a weighted rubric:\n\n{goal}", - "medium", - ) - - if not recommendations: - add( - "Graphlink-Reasoning", - "A structured plan is the safest first move when the objective is broad or ambiguous.", - f"Create the best execution plan for this objective:\n\n{goal}", - "high", - ) - add( - "Artifact / Drafter", - "Capturing the plan as a living artifact makes downstream execution easier.", - f"Draft a concise working brief for this objective:\n\n{goal}", - "medium", - ) - add( - "Quality Gate", - "A final acceptance review step helps close the loop between planning and production readiness.", - f"Define how this objective should be judged when the branch is ready:\n\n{goal}", - "low", - ) - - steps = [ - "Use the first recommended plugin to create the initial working direction.", - "Use the follow-up plugins only where they materially advance the branch.", - "Keep results inside the graph so downstream nodes inherit the execution context.", - ] - - if constraints.strip(): - steps.insert(1, f"Honor these constraints while executing: {constraints.strip()}") - - return { - "title": "Workflow Blueprint", - "mission_brief": "The app already has strong specialist nodes, so the next highest-leverage move is to orchestrate them in a tighter sequence for this goal.", - "recommended_plugins": recommendations[:4], - "workflow_steps": steps, - "deliverables": [ - "A clear first action in the recommended plugin stack", - "A graph branch whose children inherit a concrete execution plan", - ], - "risks": [ - "Using too many plugins can create busywork instead of leverage", - "If the first node is under-specified, downstream branches will inherit weak context", - ], - "success_signals": [ - "The next plugin can start with a specific seeded prompt", - "The branch has a concrete path from analysis to execution", - ], - } - - def _normalize_plan(self, plan, goal, constraints, history): - if not isinstance(plan, dict): - plan = {} - - normalized_recommendations = [] - for item in plan.get("recommended_plugins", [])[:4]: - if not isinstance(item, dict): - continue - plugin = str(item.get("plugin", "")).strip() - if plugin not in WORKFLOW_ALLOWED_PLUGINS: - continue - why = str(item.get("why", "")).strip() or "This plugin is a good fit for the current objective." - starter_prompt = str(item.get("starter_prompt", "")).strip() or goal - priority = str(item.get("priority", "medium")).strip().lower() - if priority not in {"high", "medium", "low"}: - priority = "medium" - normalized_recommendations.append({ - "plugin": plugin, - "priority": priority, - "why": why, - "starter_prompt": starter_prompt, - }) - - if not normalized_recommendations: - return self._fallback_plan(goal, constraints, history) - - def normalize_list(key, fallback_items): - items = [] - for value in plan.get(key, []): - text = str(value).strip() - if text: - items.append(text) - return items or fallback_items - - return { - "title": str(plan.get("title", "")).strip() or "Workflow Blueprint", - "mission_brief": str(plan.get("mission_brief", "")).strip() or "Use the recommended plugins in order, keeping the branch focused on the smallest set of tools that can finish the work well.", - "recommended_plugins": normalized_recommendations, - "workflow_steps": normalize_list("workflow_steps", ["Start with the highest-priority plugin recommendation and keep the branch tightly scoped."]), - "deliverables": normalize_list("deliverables", ["A branch with a concrete plan and a clear first move."]), - "risks": normalize_list("risks", ["Over-orchestrating the branch can slow execution."]), - "success_signals": normalize_list("success_signals", ["The next node has a precise, usable seeded prompt."]), - } - - def _build_markdown(self, plan): - lines = [ - f"# {plan['title']}", - "", - "## Mission Brief", - plan["mission_brief"], - "", - "## Recommended Plugins", - ] - - for item in plan["recommended_plugins"]: - lines.append(f"- **{item['plugin']}** ({item['priority'].title()}): {item['why']}") - lines.append(f" Starter prompt: `{item['starter_prompt']}`") - - lines.extend(["", "## Workflow Steps"]) - for index, step in enumerate(plan["workflow_steps"], start=1): - lines.append(f"{index}. {step}") - - lines.extend(["", "## Deliverables"]) - for item in plan["deliverables"]: - lines.append(f"- {item}") - - lines.extend(["", "## Risks"]) - for item in plan["risks"]: - lines.append(f"- {item}") - - lines.extend(["", "## Success Signals"]) - for item in plan["success_signals"]: - lines.append(f"- {item}") - - return "\n".join(lines) - - def get_response(self, goal, constraints, history): - history_lines = [] - for msg in history[-8:]: - role = msg.get("role", "user").title() - content = self._flatten_content(msg.get("content", "")) - if content.strip(): - history_lines.append(f"{role}: {content.strip()[:1200]}") - - history_text = "\n\n".join(history_lines) if history_lines else "No prior branch history." - user_prompt = f""" -Goal: -{goal} - -Constraints: -{constraints if constraints.strip() else "None provided."} - -Branch History: -{history_text} -""" - - try: - parsed = call_llm_and_parse_json(self.SYSTEM_PROMPT, user_prompt, task=config.TASK_CHAT) - normalized = self._normalize_plan(parsed, goal, constraints, history) - except Exception: - normalized = self._fallback_plan(goal, constraints, history) - - normalized["blueprint_markdown"] = self._build_markdown(normalized) - return normalized - - -class WorkflowWorkerThread(QThread): - finished = Signal(dict) - error = Signal(str) - - def __init__(self, goal, constraints, history): - super().__init__() - self.goal = goal - self.constraints = constraints - self.history = history - self.agent = WorkflowArchitectAgent() - self._is_running = True - - def run(self): - try: - if not self._is_running: - return - result = self.agent.get_response(self.goal, self.constraints, self.history) - if self._is_running: - self.finished.emit(result) - except Exception as exc: - if self._is_running: - self.error.emit(str(exc)) - finally: - self._is_running = False - - def stop(self): - self._is_running = False - - -class WorkflowRecommendationCard(QFrame): - add_requested = Signal(str, str) - - def __init__(self, recommendation, accent_color, parent=None): - super().__init__(parent) - self.recommendation = recommendation - self.setObjectName("workflowRecommendationCard") - self.setStyleSheet(f""" - QFrame#workflowRecommendationCard {{ - background-color: #24282d; - border: 1px solid #353b43; - border-radius: 8px; - }} - QLabel {{ - background: transparent; - color: #d7dce2; - }} - QLabel#workflowPluginTitle {{ - color: #ffffff; - font-weight: bold; - font-size: 13px; - }} - QLabel#workflowMeta {{ - color: {accent_color}; - background-color: transparent; - font-size: 10px; - font-weight: bold; - }} - QLabel#workflowReason {{ - color: #c8d0d8; - font-size: 12px; - }} - QLabel#workflowPrompt {{ - color: #a9b4bf; - font-size: 11px; - padding-top: 4px; - border-top: 1px solid #31363d; - }} - QPushButton {{ - background-color: {accent_color}; - color: black; - border: none; - border-radius: 6px; - padding: 6px 12px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: #79d884; - }} - """) - - layout = QVBoxLayout(self) - layout.setContentsMargins(12, 10, 12, 10) - layout.setSpacing(8) - - header_layout = QHBoxLayout() - header_layout.setContentsMargins(0, 0, 0, 0) - icon_label = QLabel() - icon_name = WORKFLOW_PLUGIN_ICONS.get(recommendation["plugin"], "fa5s.puzzle-piece") - icon_label.setPixmap(qta.icon(icon_name, color=accent_color).pixmap(16, 16)) - header_layout.addWidget(icon_label) - - title_label = QLabel(recommendation["plugin"]) - title_label.setObjectName("workflowPluginTitle") - header_layout.addWidget(title_label) - header_layout.addStretch() - - priority_label = QLabel(recommendation["priority"].upper()) - priority_label.setObjectName("workflowMeta") - header_layout.addWidget(priority_label) - layout.addLayout(header_layout) - - why_label = QLabel(recommendation["why"]) - why_label.setObjectName("workflowReason") - why_label.setWordWrap(True) - layout.addWidget(why_label) - - prompt_label = QLabel(f"Seed: {recommendation['starter_prompt']}") - prompt_label.setObjectName("workflowPrompt") - prompt_label.setWordWrap(True) - layout.addWidget(prompt_label) - - action_layout = QHBoxLayout() - action_layout.setContentsMargins(0, 0, 0, 0) - action_layout.addStretch() - add_button = QPushButton("Add Node") - add_button.clicked.connect(lambda: self.add_requested.emit(recommendation["plugin"], recommendation["starter_prompt"])) - action_layout.addWidget(add_button) - layout.addLayout(action_layout) - - -class WorkflowConnectionItem(ConnectionItem): - def paint(self, painter, option, widget=None): - if not (self.start_node and self.end_node): - return - - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Green"]["color"]) - pen = QPen(node_color, 2, Qt.PenStyle.DashLine) - pen.setCapStyle(Qt.PenCapStyle.RoundCap) - - if self.hover: - pen.setWidth(3) - - painter.setPen(pen) - painter.drawPath(self.path) - - if self.is_animating: - for arrow in self.arrows: - self.drawArrow(painter, arrow["pos"], node_color) - - def drawArrow(self, painter, pos, color): - if pos < 0 or pos > 1: - return - point = self.path.pointAtPercent(pos) - angle = self.path.angleAtPercent(pos) - - arrow = QPainterPath() - arrow.moveTo(-self.arrow_size, -self.arrow_size / 2) - arrow.lineTo(0, 0) - arrow.lineTo(-self.arrow_size, self.arrow_size / 2) - - painter.save() - painter.translate(point) - painter.rotate(-angle) - painter.setBrush(color) - painter.setPen(Qt.PenStyle.NoPen) - painter.drawPath(arrow) - painter.restore() - - -class WorkflowNode(QGraphicsObject, HoverAnimationMixin): - supports_branch_context_toggle = True - workflow_requested = Signal(object) - plugin_requested = Signal(object, str, str) - - NODE_WIDTH = 760 - NODE_HEIGHT = 690 - COLLAPSED_WIDTH = 260 - COLLAPSED_HEIGHT = 40 - CONNECTION_DOT_RADIUS = 5 - CONNECTION_DOT_OFFSET = 0 - - def __init__(self, parent_node, parent=None): - super().__init__(parent) - HoverAnimationMixin.__init__(self) - self.parent_node = parent_node - self.children = [] - self.worker_thread = None - self.is_user = False - self.conversation_history = [] - self.goal = "" - self.constraints = "" - self.status = "Idle" - self.blueprint_markdown = "" - self.recommendations = [] - self.is_search_match = False - self.is_collapsed = False - self.collapse_button_rect = QRectF() - - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemIsMovable) - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemIsSelectable) - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemSendsGeometryChanges) - self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemUsesExtendedStyleOption) - self.setAcceptHoverEvents(True) - self.hovered = False - self._render_lod_mode = "full" - - self.widget = QWidget() - self.widget.setObjectName("workflowMainWidget") - self.widget.setFixedSize(self.NODE_WIDTH, self.NODE_HEIGHT) - self.widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - self.widget.setStyleSheet(""" - QWidget#workflowMainWidget { - background-color: transparent; - color: #e0e0e0; - } - QWidget#workflowMainWidget QLabel { - background-color: transparent; - } - """) - - self._setup_ui() - - self.proxy = QGraphicsProxyWidget(self) - self.proxy.setWidget(self.widget) - - @property - def width(self): - return self.COLLAPSED_WIDTH if self.is_collapsed else self.NODE_WIDTH - - @property - def height(self): - return self.COLLAPSED_HEIGHT if self.is_collapsed else self.NODE_HEIGHT - - def set_collapsed(self, collapsed): - if self.is_collapsed != collapsed: - self.is_collapsed = collapsed - self.proxy.setVisible(not self.is_collapsed and self._render_lod_mode == "full") - self.prepareGeometryChange() - if self.scene(): - self.sync_view_lod() - self.scene().update_connections() - self.scene().nodeMoved(self) - self.update() - - def toggle_collapse(self): - self.set_collapsed(not self.is_collapsed) - - def sync_view_lod(self, view_rect=None, zoom=None): - sync_proxy_render_state(self, view_rect, zoom) - if not self.is_collapsed: - self.update() - - def _setup_ui(self): - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Green"]["color"]) - brightness = (node_color.red() * 299 + node_color.green() * 587 + node_color.blue() * 114) / 1000 - button_text_color = "black" if brightness > 128 else "white" - - self.widget.setStyleSheet(f""" - QWidget#workflowMainWidget {{ - background-color: transparent; - color: #e0e0e0; - font-family: 'Segoe UI', sans-serif; - }} - QWidget#workflowMainWidget QLabel {{ - background-color: transparent; - }} - QFrame#workflowBriefShell {{ - background-color: #202327; - border: 1px solid #353b43; - border-radius: 10px; - }} - QLabel#workflowSectionTitle {{ - color: #ffffff; - font-size: 12px; - font-weight: bold; - }} - QLabel#workflowSectionHint {{ - color: #95a0ab; - font-size: 11px; - }} - QLabel#workflowFieldLabel {{ - color: #c7cdd4; - font-size: 11px; - font-weight: bold; - }} - QLabel#workflowMetricBadge {{ - color: {node_color.name()}; - background-color: rgba(87, 214, 111, 0.1); - border: 1px solid rgba(87, 214, 111, 0.24); - border-radius: 10px; - padding: 3px 8px; - font-size: 11px; - font-weight: bold; - }} - QTextEdit {{ - background-color: #15181b; - border: 1px solid #2f353d; - color: #d7dce2; - border-radius: 7px; - padding: 8px; - selection-background-color: #264f78; - font-family: 'Segoe UI', sans-serif; - }} - QTextEdit:focus {{ - border: 1px solid {node_color.name()}; - }} - QTabWidget::pane {{ - border: 1px solid #3a4048; - background: #1d2024; - border-radius: 8px; - }} - QTabBar::tab {{ - background: #25282d; - color: #97a1ab; - padding: 8px 14px; - border: 1px solid #3a4048; - border-bottom: none; - border-top-left-radius: 6px; - border-top-right-radius: 6px; - margin-right: 2px; - font-weight: bold; - }} - QTabBar::tab:selected {{ - background: #1d2024; - color: #ffffff; - border-top: 2px solid {node_color.name()}; - border-bottom: 1px solid #1d2024; - }} - QTabBar::tab:hover:!selected {{ - background: #2d3136; - color: #ffffff; - }} - """) - - main_layout = QVBoxLayout(self.widget) - main_layout.setContentsMargins(15, 15, 15, 15) - main_layout.setSpacing(10) - - header_layout = QHBoxLayout() - header_layout.setContentsMargins(4, 0, 4, 0) - header_layout.setSpacing(8) - icon = QLabel() - icon.setPixmap(qta.icon("fa5s.project-diagram", color=node_color).pixmap(18, 18)) - header_layout.addWidget(icon) - title_label = QLabel("Workflow Architect") - title_label.setStyleSheet(f"font-weight: bold; font-size: 14px; color: {node_color.name()};") - header_layout.addWidget(title_label) - header_layout.addStretch() - main_layout.addLayout(header_layout) - - line = QFrame() - line.setFrameShape(QFrame.Shape.HLine) - line.setStyleSheet("background-color: #3f3f3f; border: none; height: 1px;") - main_layout.addWidget(line) - - briefing_surface = QFrame() - briefing_surface.setObjectName("workflowBriefShell") - briefing_surface.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - briefing_layout = QVBoxLayout(briefing_surface) - briefing_layout.setContentsMargins(14, 12, 14, 12) - briefing_layout.setSpacing(8) - - briefing_header = QHBoxLayout() - briefing_header.setContentsMargins(0, 0, 0, 0) - briefing_title = QLabel("Mission Brief") - briefing_title.setObjectName("workflowSectionTitle") - briefing_header.addWidget(briefing_title) - briefing_header.addStretch() - briefing_hint = QLabel("Goal + constraints") - briefing_hint.setObjectName("workflowMetricBadge") - briefing_header.addWidget(briefing_hint) - briefing_layout.addLayout(briefing_header) - - mission_label = QLabel("Mission") - mission_label.setObjectName("workflowFieldLabel") - briefing_layout.addWidget(mission_label) - self.goal_input = QTextEdit() - self.goal_input.setPlaceholderText("Describe the goal, outcome, or feature you want the branch to accomplish...") - self.goal_input.setFixedHeight(96) - self.goal_input.setStyleSheet("QTextEdit { font-size: 12px; }" + WORKFLOW_SCROLLBAR_STYLE) - self.goal_input.textChanged.connect(self._on_goal_changed) - briefing_layout.addWidget(self.goal_input) - - constraints_label = QLabel("Constraints / Context") - constraints_label.setObjectName("workflowFieldLabel") - briefing_layout.addWidget(constraints_label) - self.constraints_input = QTextEdit() - self.constraints_input.setPlaceholderText("Optional: budget, quality bar, constraints, audience, time pressure, tech stack...") - self.constraints_input.setFixedHeight(68) - self.constraints_input.setStyleSheet("QTextEdit { font-size: 12px; }" + WORKFLOW_SCROLLBAR_STYLE) - self.constraints_input.textChanged.connect(self._on_constraints_changed) - briefing_layout.addWidget(self.constraints_input) - - controls_panel = QWidget() - controls_layout = QHBoxLayout(controls_panel) - controls_layout.setContentsMargins(0, 4, 0, 0) - controls_layout.setSpacing(10) - self.run_button = QPushButton("Design Workflow") - self.run_button.setIcon(qta.icon("fa5s.magic", color=button_text_color)) - self.run_button.clicked.connect(lambda: self.workflow_requested.emit(self)) - controls_layout.addWidget(self.run_button) - - self.status_label = QLabel("Idle") - self.status_label.setObjectName("workflowMetricBadge") - controls_layout.addWidget(self.status_label) - controls_layout.addStretch() - status_hint = QLabel("Uses the current branch context to generate a sequenced plugin plan.") - status_hint.setObjectName("workflowSectionHint") - controls_layout.addWidget(status_hint) - briefing_layout.addWidget(controls_panel) - - main_layout.addWidget(briefing_surface) - - self.workspace_tabs = QTabWidget() - self.workspace_tabs.setDocumentMode(True) - - blueprint_panel = QWidget() - blueprint_layout = QVBoxLayout(blueprint_panel) - blueprint_layout.setContentsMargins(0, 0, 0, 0) - blueprint_layout.setSpacing(8) - - blueprint_subtitle = QLabel("Execution blueprint and ordered workflow steps.") - blueprint_subtitle.setObjectName("workflowSectionHint") - blueprint_subtitle.setWordWrap(True) - blueprint_layout.addWidget(blueprint_subtitle) - - self.plan_display = QTextEdit() - self.plan_display.setReadOnly(True) - self.plan_display.setPlaceholderText("Your recommended plugin sequence and execution blueprint will appear here...") - self.plan_display.document().setDefaultStyleSheet(""" - h1, h2 { color: #ffffff; } - p, li { color: #d6d6d6; font-family: 'Segoe UI', sans-serif; font-size: 12px; } - code { background-color: #31353b; padding: 2px 4px; border-radius: 4px; color: #b5f5bf; } - """) - self.plan_display.setStyleSheet(""" - QTextEdit { - font-size: 12px; - background-color: transparent; - border: none; - padding: 6px 2px 2px 2px; - } - """ + WORKFLOW_SCROLLBAR_STYLE) - blueprint_layout.addWidget(self.plan_display, stretch=1) - - recommendations_panel = QWidget() - recommendations_layout = QVBoxLayout(recommendations_panel) - recommendations_layout.setContentsMargins(0, 0, 0, 0) - recommendations_layout.setSpacing(8) - - recommendation_header = QHBoxLayout() - recommendation_header.setContentsMargins(0, 0, 0, 0) - recommendation_title = QLabel("Launch the recommended specialist nodes directly from this list.") - recommendation_title.setObjectName("workflowSectionHint") - recommendation_title.setWordWrap(True) - recommendation_header.addWidget(recommendation_title, 1) - recommendation_header.addStretch() - self.recommendation_count_label = QLabel("0") - self.recommendation_count_label.setObjectName("workflowMetricBadge") - recommendation_header.addWidget(self.recommendation_count_label) - recommendations_layout.addLayout(recommendation_header) - - self.recommendation_scroll = QScrollArea() - self.recommendation_scroll.setWidgetResizable(True) - self.recommendation_scroll.setFrameShape(QFrame.Shape.NoFrame) - self.recommendation_scroll.setStyleSheet(""" - QScrollArea { - background-color: transparent; - border: none; - } - QScrollArea > QWidget > QWidget { - background: transparent; - } - """ + WORKFLOW_SCROLLBAR_STYLE) - - self.recommendation_content = QWidget() - self.recommendation_content.setStyleSheet("background: transparent;") - self.recommendation_layout = QVBoxLayout(self.recommendation_content) - self.recommendation_layout.setContentsMargins(0, 6, 0, 0) - self.recommendation_layout.setSpacing(8) - self.recommendation_layout.addWidget(self._build_empty_state()) - self.recommendation_layout.addStretch() - self.recommendation_scroll.setWidget(self.recommendation_content) - recommendations_layout.addWidget(self.recommendation_scroll) - - self.workspace_tabs.addTab(blueprint_panel, qta.icon("fa5s.map-signs", color="#cccccc"), "Blueprint") - self.workspace_tabs.addTab(recommendations_panel, qta.icon("fa5s.project-diagram", color="#cccccc"), "Recommended Nodes (0)") - main_layout.addWidget(self.workspace_tabs, stretch=1) - - self.run_button.setStyleSheet(f""" - QPushButton {{ - background-color: {node_color.name()}; - color: {button_text_color}; - border: none; - border-radius: 8px; - padding: 9px 16px; - font-weight: bold; - }} - QPushButton:hover {{ - background-color: {node_color.lighter(110).name()}; - }} - QPushButton:disabled {{ - background-color: #555555; - color: #cccccc; - }} - """) - - def _build_empty_state(self): - label = QLabel("No recommendations yet. Run the workflow architect to generate the next best specialist nodes for this branch.") - label.setWordWrap(True) - label.setStyleSheet("color: #8b929b; padding: 12px; background-color: #171a1d; border: 1px dashed #353b42; border-radius: 8px;") - return label - - def _clear_recommendations(self): - while self.recommendation_layout.count(): - item = self.recommendation_layout.takeAt(0) - widget = item.widget() - if widget is not None: - widget.deleteLater() - - def _on_goal_changed(self): - self.goal = self.goal_input.toPlainText() - - def _on_constraints_changed(self): - self.constraints = self.constraints_input.toPlainText() - - def get_goal(self): - return self.goal_input.toPlainText() - - def seed_prompt(self, text): - """Protocol method used by graphite_window_actions.instantiate_seeded_plugin.""" - self.goal_input.setPlainText(text) - - def get_constraints(self): - return self.constraints_input.toPlainText() - - def set_running_state(self, is_running): - self.run_button.setEnabled(not is_running) - self.goal_input.setReadOnly(is_running) - self.constraints_input.setReadOnly(is_running) - self.run_button.setText("Designing..." if is_running else "Design Workflow") - self.set_status("Designing workflow..." if is_running else "Completed") - - def set_status(self, status_text): - self.status = status_text - self.status_label.setText(status_text) - if "Designing" in status_text: - info_color = get_semantic_color("status_info") - self.status_label.setStyleSheet(f"color: {info_color.name()}; background-color: rgba({info_color.red()}, {info_color.green()}, {info_color.blue()}, 0.1); border: 1px solid rgba({info_color.red()}, {info_color.green()}, {info_color.blue()}, 0.22); border-radius: 10px; padding: 3px 8px; font-size: 11px; font-weight: bold;") - elif "Completed" in status_text: - success_color = get_semantic_color("status_success") - self.status_label.setStyleSheet(f"color: {success_color.name()}; background-color: rgba({success_color.red()}, {success_color.green()}, {success_color.blue()}, 0.1); border: 1px solid rgba({success_color.red()}, {success_color.green()}, {success_color.blue()}, 0.22); border-radius: 10px; padding: 3px 8px; font-size: 11px; font-weight: bold;") - elif "Error" in status_text: - error_color = get_semantic_color("status_error") - self.status_label.setStyleSheet(f"color: {error_color.name()}; background-color: rgba({error_color.red()}, {error_color.green()}, {error_color.blue()}, 0.1); border: 1px solid rgba({error_color.red()}, {error_color.green()}, {error_color.blue()}, 0.22); border-radius: 10px; padding: 3px 8px; font-size: 11px; font-weight: bold;") - else: - self.status_label.setStyleSheet("color: #9aa3ad; background-color: rgba(154, 163, 173, 0.08); border: 1px solid rgba(154, 163, 173, 0.18); border-radius: 10px; padding: 3px 8px; font-size: 11px; font-weight: bold;") - - def set_plan(self, plan): - self.blueprint_markdown = plan.get("blueprint_markdown", "") - self.recommendations = plan.get("recommended_plugins", []) - self.plan_display.setMarkdown(self.blueprint_markdown) - self.recommendation_count_label.setText(str(len(self.recommendations))) - self.workspace_tabs.setTabText(1, f"Recommended Nodes ({len(self.recommendations)})") - - palette = get_current_palette() - accent_color = palette.FRAME_COLORS["Green"]["color"] - self._clear_recommendations() - - if not self.recommendations: - self.recommendation_layout.addWidget(self._build_empty_state()) - else: - for recommendation in self.recommendations: - card = WorkflowRecommendationCard(recommendation, accent_color) - card.add_requested.connect(lambda plugin, prompt, node=self: self.plugin_requested.emit(node, plugin, prompt)) - self.recommendation_layout.addWidget(card) - self.recommendation_layout.addStretch() - self.set_status("Completed") - - def set_error(self, error_message): - self.blueprint_markdown = f"## Error\n\n{error_message}" - self.plan_display.setMarkdown(self.blueprint_markdown) - self.recommendations = [] - self.recommendation_count_label.setText("0") - self.workspace_tabs.setTabText(1, "Recommended Nodes (0)") - self._clear_recommendations() - self.recommendation_layout.addWidget(self._build_empty_state()) - self.recommendation_layout.addStretch() - self.set_status(f"Error: {error_message}") - - def boundingRect(self): - padding = self.CONNECTION_DOT_OFFSET + self.CONNECTION_DOT_RADIUS - return QRectF(-padding, 0, self.width + 2 * padding, self.height) - - def paint(self, painter, option, widget=None): - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - palette = get_current_palette() - node_color = QColor(palette.FRAME_COLORS["Green"]["color"]) - render_mode = getattr(self, "_render_lod_mode", "full") - - path = QPainterPath() - path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2d2d2d")) - - pen = QPen(node_color, 1.5) - if self.isSelected(): - pen = QPen(palette.SELECTION, 2) - elif self.is_search_match: - pen = QPen(get_semantic_color("search_highlight"), 2) - elif self.hovered: - pen = QPen(QColor("#ffffff"), 2) - - painter.setPen(pen) - painter.drawPath(path) - - dot_color = node_color if not (self.isSelected() or self.hovered) else pen.color().lighter(110) - painter.setBrush(dot_color) - painter.setPen(Qt.PenStyle.NoPen) - - dot_rect_left = QRectF(-self.CONNECTION_DOT_RADIUS, (self.height / 2) - self.CONNECTION_DOT_RADIUS, self.CONNECTION_DOT_RADIUS * 2, self.CONNECTION_DOT_RADIUS * 2) - painter.drawPie(dot_rect_left, 90 * 16, -180 * 16) - - dot_rect_right = QRectF(self.width - self.CONNECTION_DOT_RADIUS, (self.height / 2) - self.CONNECTION_DOT_RADIUS, self.CONNECTION_DOT_RADIUS * 2, self.CONNECTION_DOT_RADIUS * 2) - painter.drawPie(dot_rect_right, 90 * 16, 180 * 16) - - if not self.is_collapsed and render_mode != "full": - self.collapse_button_rect = QRectF() - draw_lod_card( - painter, - QRectF(0, 0, self.width, self.height), - accent=node_color, - selection_color=palette.SELECTION, - title="Workflow Architect", - subtitle=self.status or "Idle", - preview=preview_text(self.goal, self.constraints, self.blueprint_markdown, fallback="Design a workflow"), - badge="PLAN", - mode=render_mode, - selected=self.isSelected(), - hovered=self.hovered, - search_match=self.is_search_match, - connection_radius=self.CONNECTION_DOT_RADIUS, - ) - return - - if self.is_collapsed: - painter.setPen(QColor("#ffffff")) - painter.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold)) - painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "Workflow Architect") - qta.icon("fa5s.project-diagram", color=node_color.name()).paint(painter, QRect(10, 10, 20, 20)) - self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - qta.icon("fa5s.expand-arrows-alt", color="#ffffff" if self.hovered else "#888888").paint(painter, QRect(int(self.width - 30), 10, 20, 20)) - else: - if self.hovered: - self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - painter.setBrush(QColor(255, 255, 255, 30)) - painter.setPen(QColor(255, 255, 255, 150)) - painter.drawRoundedRect(self.collapse_button_rect.adjusted(6, 6, -6, -6), 4, 4) - painter.setPen(QPen(QColor("#ffffff"), 2)) - center = self.collapse_button_rect.center() - painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) - else: - self.collapse_button_rect = QRectF() - - def mousePressEvent(self, event): - if self.collapse_button_rect.contains(event.pos()): - self.toggle_collapse() - event.accept() - return - - if event.button() == Qt.MouseButton.LeftButton and self.scene(): - self.scene().is_dragging_item = True - if hasattr(self.scene(), "window"): - self.scene().window.setCurrentNode(self) - super().mousePressEvent(event) - - def contextMenuEvent(self, event): - menu = PluginNodeContextMenu(self) - menu.exec(event.screenPos()) - - def mouseReleaseEvent(self, event): - if self.scene(): - self.scene().is_dragging_item = False - self.scene()._clear_smart_guides() - super().mouseReleaseEvent(event) - - def itemChange(self, change, value): - if change == QGraphicsObject.GraphicsItemChange.ItemPositionChange and self.scene() and self.scene().is_dragging_item: - return self.scene().snap_position(self, value) - if change == QGraphicsObject.GraphicsItemChange.ItemPositionHasChanged and self.scene(): - self.scene().nodeMoved(self) - return super().itemChange(change, value) - - def hoverEnterEvent(self, event): - self._handle_hover_enter(event) - super().hoverEnterEvent(event) - - def hoverLeaveEvent(self, event): - self._handle_hover_leave(event) - super().hoverLeaveEvent(event) diff --git a/graphite_app/graphite_plugins/quality_gate/__init__.py b/graphite_app/graphite_plugins/quality_gate/__init__.py deleted file mode 100644 index 305572c..0000000 --- a/graphite_app/graphite_plugins/quality_gate/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Qt-free Quality Gate scoring package (see doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.7).""" diff --git a/graphite_app/graphite_plugins/quality_gate/scoring.py b/graphite_app/graphite_plugins/quality_gate/scoring.py deleted file mode 100644 index 8bb27bd..0000000 --- a/graphite_app/graphite_plugins/quality_gate/scoring.py +++ /dev/null @@ -1,593 +0,0 @@ -"""Quality Gate scoring/rubric engine, extracted out of graphite_plugin_quality_gate.py -(see doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.7) so it can be unit tested without -constructing any Qt widget or GUI application object. - -Deliberately NOT merged with graphite_plugins.code_review.scoring.CodeReviewAnalyzer - -the two rubrics score genuinely different things (branch release-readiness vs. code -quality) and only superficially resemble each other (both call an LLM and fall back to -a heuristic when it's unavailable). -""" - -import re - -import graphite_config as config -from graphite_plugins.common.llm_json import call_llm_and_parse_json, extract_json_object - - -QUALITY_GATE_PLUGIN_ICONS = { - "Py-Coder": "fa5s.code", - "Gitlink": "fa5s.link", - "Execution Sandbox": "fa5s.shield-alt", - "Artifact / Drafter": "fa5s.file-alt", - "Graphlink-Web": "fa5s.globe-americas", - "Conversation Node": "fa5s.comments", - "Graphlink-Reasoning": "fa5s.brain", - "HTML Renderer": "fa5s.window-maximize", - "Workflow Architect": "fa5s.project-diagram", -} - -QUALITY_GATE_ALLOWED_PLUGINS = list(QUALITY_GATE_PLUGIN_ICONS.keys()) - - -def _flatten_content(content): - if isinstance(content, str): - return content - if isinstance(content, list): - parts = [] - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - parts.append(item.get("text", "")) - return "\n".join(part for part in parts if part) - return str(content) - - -def _clean_text(text, limit=2500): - text = re.sub(r"\n{3,}", "\n\n", text or "").strip() - if len(text) > limit: - return text[: limit - 3].rstrip() + "..." - return text - - -def _read_widget_text(widget): - if not widget: - return "" - - for method_name in ("toPlainText", "text", "toHtml"): - method = getattr(widget, method_name, None) - if callable(method): - try: - value = method() - if isinstance(value, str): - return value - except Exception: - pass - return "" - - -_NON_PLUGIN_NODE_LABELS = { - # Core node types that aren't registered plugins (see PLUGIN_REGISTRY in - # graphite_plugin_portal.py) but can still appear in a branch transcript. - "ChatNode": "Chat Node", -} - - -def _node_label(node): - class_name = node.__class__.__name__ - if class_name in _NON_PLUGIN_NODE_LABELS: - return _NON_PLUGIN_NODE_LABELS[class_name] - - # Deferred import: graphite_plugin_portal imports graphite_plugin_quality_gate (to - # register the Quality Gate plugin), so importing it back at module load time would - # be circular. By the time _node_label() actually runs, both modules are loaded. - from graphite_plugins.graphite_plugin_portal import get_display_name_for_node - - return get_display_name_for_node(node) - - -def _extract_node_text(node): - parts = [] - - if hasattr(node, "text") and isinstance(getattr(node, "text"), str): - parts.append(node.text) - - if hasattr(node, "conversation_history"): - for message in getattr(node, "conversation_history", [])[-6:]: - role = message.get("role", "unknown").title() - content = _flatten_content(message.get("content", "")) - if content.strip(): - parts.append(f"{role}: {content.strip()}") - - for attr in ("prompt", "thinking_text", "thought_process", "blueprint_markdown", "review_markdown", "html_content"): - value = getattr(node, attr, "") - if isinstance(value, str) and value.strip(): - parts.append(value) - - for getter_name, prefix in ( - ("get_goal", "Goal"), - ("get_constraints", "Constraints"), - ("get_criteria", "Acceptance Criteria"), - ("get_artifact_content", "Artifact"), - ("get_requirements", "Requirements"), - ("get_code", "Code"), - ): - getter = getattr(node, getter_name, None) - if callable(getter): - try: - value = getter().strip() - if value: - parts.append(f"{prefix}:\n{value}") - except Exception: - pass - - for widget_name in ( - "query_input", - "prompt_input", - "instruction_input", - "message_input", - "output_display", - "ai_analysis_display", - ): - widget = getattr(node, widget_name, None) - value = _read_widget_text(widget).strip() - if value: - parts.append(value) - - if hasattr(node, "summary_text") and isinstance(getattr(node, "summary_text"), str): - if node.summary_text.strip(): - parts.append(node.summary_text) - - unique_parts = [] - seen = set() - for part in parts: - cleaned = _clean_text(part, limit=1200) - if cleaned and cleaned not in seen: - unique_parts.append(cleaned) - seen.add(cleaned) - return "\n\n".join(unique_parts) - - -def _collect_branch_nodes(node): - lineage = [] - seen = set() - cursor = node - while cursor and id(cursor) not in seen: - lineage.append(cursor) - seen.add(id(cursor)) - cursor = getattr(cursor, "parent_node", None) - lineage.reverse() - return lineage - - -def build_quality_gate_payload(node, include_branch_context=True): - lineage = _collect_branch_nodes(node) if include_branch_context else ([node] if node else []) - sections = [] - labels = [] - - for index, branch_node in enumerate(lineage, start=1): - label = _node_label(branch_node) - labels.append(label) - content = _extract_node_text(branch_node) - if not content: - continue - sections.append(f"Step {index}: {label}\n{content}") - - transcript = "\n\n---\n\n".join(sections) if sections else "No branch transcript available." - preview = "\n\n".join(sections[-3:]) if sections else transcript - branch_label = _node_label(node) if node else "Current Branch" - - return { - "label": branch_label, - "depth": len(lineage), - "node_labels": labels, - "transcript": _clean_text(transcript, limit=14000), - "preview": _clean_text(preview, limit=3200), - } - - -class QualityGateAnalyzer: - SYSTEM_PROMPT = """ -You are Graphlink's Quality Gate. -Your job is to evaluate whether the current branch is actually ready for production, release, or stakeholder handoff. - -Allowed follow-up plugins: -- Py-Coder -- Gitlink -- Execution Sandbox -- Artifact / Drafter -- Graphlink-Web -- Conversation Node -- Graphlink-Reasoning -- HTML Renderer -- Workflow Architect - -Rules: -1. Be tough but fair. Do not confuse momentum with readiness. -2. Judge against explicit acceptance criteria first, then against missing evidence and branch quality. -3. If evidence is missing, say so directly. -4. Recommend at most 4 follow-up plugins. -5. Only recommend HTML Renderer when UI or rendered output is directly relevant. -6. Use verdict values only from: ready, needs_work, blocked. -7. Output valid JSON only. No markdown fences, no preamble. - -Return exactly this shape: -{ - "title": "Short review title", - "verdict": "ready", - "readiness_score": 88, - "overview": "2-4 sentence review summary", - "strengths": ["..."], - "blockers": ["..."], - "risks": ["..."], - "missing_evidence": ["..."], - "recommended_plugins": [ - { - "plugin": "Py-Coder", - "priority": "high", - "why": "Why this plugin closes the highest-value gap", - "starter_prompt": "The exact seeded instruction for that plugin" - } - ], - "next_actions": ["..."], - "release_decision": "One concise release recommendation", - "note_summary": "Concise summary suitable for a note" -} -""" - - def _clean_json_response(self, raw_text): - # Delegates to the shared regex (doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section - # 1.6/3.5) - kept as a wrapper so this method's existing call site is unchanged. - return extract_json_object(raw_text) - - def _fallback_review(self, goal, criteria, payload): - transcript = payload.get("transcript", "") - lowered = f"{goal}\n{criteria}\n{transcript}".lower() - - has_code = bool(re.search(r"\b(def |class |import |function|python|script|html|css|javascript|tsx|sql|```)\b", transcript, re.IGNORECASE)) - has_tests = bool(re.search(r"\b(test|tests|pytest|unit test|integration test|assert|verification|validated)\b", transcript, re.IGNORECASE)) - has_errors = bool(re.search(r"\b(error|failed|failure|traceback|exception|bug|broken)\b", transcript, re.IGNORECASE)) - has_sources = bool(re.search(r"\b(source|sources|http|www\\.|citation|citations|reference|references|research)\b", transcript, re.IGNORECASE)) - has_plan = bool(re.search(r"\b(plan|workflow|step|deliverable|mission|roadmap|sequence)\b", transcript, re.IGNORECASE)) - has_artifact = bool(re.search(r"\b(spec|brief|proposal|report|document|markdown|artifact)\b", transcript, re.IGNORECASE)) - repo_goal = any(term in lowered for term in ("repo", "repository", "github", "git", "codebase", "checkout", "monorepo", "branch")) - build_goal = any(term in lowered for term in ("build", "implement", "ship", "production", "release", "feature", "fix", "app", "ui")) - research_goal = any(term in lowered for term in ("research", "latest", "market", "current", "trend", "competitor", "news")) - doc_goal = any(term in lowered for term in ("spec", "proposal", "brief", "report", "doc", "documentation")) - - score = 46 - if len(transcript) > 450: - score += 8 - if has_plan: - score += 8 - if has_code: - score += 12 - if has_tests: - score += 14 - if has_sources: - score += 8 - if has_artifact: - score += 6 - if criteria.strip(): - score += 8 - else: - score -= 10 - if has_errors: - score -= 18 - if len(transcript) < 240: - score -= 12 - score = max(8, min(96, score)) - - strengths = [] - blockers = [] - risks = [] - missing_evidence = [] - recommendations = [] - - def add(plugin, why, starter_prompt, priority="medium"): - if plugin in QUALITY_GATE_ALLOWED_PLUGINS and not any(item["plugin"] == plugin for item in recommendations): - recommendations.append({ - "plugin": plugin, - "priority": priority, - "why": why, - "starter_prompt": starter_prompt, - }) - - if has_plan: - strengths.append("The branch already shows structured intent instead of staying purely exploratory.") - if has_code: - strengths.append("There is concrete implementation or executable detail to review.") - if has_tests: - strengths.append("Verification language is present, which raises confidence in branch quality.") - if has_sources: - strengths.append("The branch includes external grounding or evidence rather than relying only on intuition.") - if has_artifact: - strengths.append("Important decisions are captured in a more durable artifact, not just transient chat output.") - if not strengths: - strengths.append("The branch has a visible direction and can be hardened from here.") - - if not criteria.strip(): - blockers.append("Acceptance criteria are not explicit yet, so readiness cannot be judged against a stable shipping bar.") - missing_evidence.append("A concrete release checklist or acceptance bar is missing.") - - if build_goal and not has_code: - blockers.append("The goal sounds implementation-oriented, but the branch does not yet show enough concrete build evidence.") - missing_evidence.append("Implementation evidence is still too thin for a production verdict.") - - if has_code and not has_tests: - blockers.append("Implementation exists, but there is no clear verification or test evidence yet.") - missing_evidence.append("A reproducible validation pass or test result is missing.") - - if has_errors: - blockers.append("The branch still contains failure/error signals that suggest unresolved issues remain.") - - if research_goal and not has_sources: - risks.append("Research assumptions may be stale or ungrounded because no clear source evidence is attached.") - missing_evidence.append("Source-backed evidence for the key factual claims is missing.") - - if doc_goal and not has_artifact: - risks.append("The deliverable appears document-heavy, but the polished artifact has not been consolidated yet.") - - if not has_plan: - risks.append("Without a tighter execution sequence, the next branch steps may drift or duplicate effort.") - - if len(transcript) < 240: - risks.append("The branch is still sparse enough that a confident production-style verdict would be premature.") - - if not blockers and score >= 84: - verdict = "ready" - elif has_errors or score < 52: - verdict = "blocked" - else: - verdict = "needs_work" - - if not criteria.strip(): - add( - "Artifact / Drafter", - "A release review needs a visible acceptance checklist before the branch can be judged fairly.", - f"Turn this goal into a crisp acceptance checklist and shipping bar:\n\n{goal}", - "high", - ) - - if research_goal and not has_sources: - add( - "Graphlink-Web", - "Current or external claims need evidence before the branch can be trusted.", - goal, - "high", - ) - - if repo_goal and (not has_code or len(transcript) < 500): - add( - "Gitlink", - "This branch would benefit from explicit repository grounding before more implementation or review work happens.", - f"Connect this branch to the relevant GitHub repository, load the most useful files, and package the repo context for the next implementation pass.\n\nGoal:\n{goal}", - "high", - ) - - if build_goal and (not has_code or has_errors): - plugin = "Execution Sandbox" if any(term in lowered for term in ("dependency", "dependencies", "requirements", "venv", "virtualenv", "install", "package")) else "Py-Coder" - add( - plugin, - "The highest-leverage gap is still in implementation quality or execution evidence.", - f"Close the highest-risk delivery gaps for this goal and show concrete evidence:\n\nGoal:\n{goal}\n\nAcceptance criteria:\n{criteria or 'Define the acceptance criteria first.'}", - "high", - ) - - if has_code and not has_tests: - add( - "Execution Sandbox", - "A reproducible validation pass will convert this from plausible to defensible.", - f"Run the strongest verification pass you can for this branch and capture the results.\n\nGoal:\n{goal}\n\nAcceptance criteria:\n{criteria or 'Define the acceptance criteria first.'}", - "high", - ) - - if not has_plan or any(term in lowered for term in ("architecture", "tradeoff", "ambiguous", "unclear", "complex")): - add( - "Graphlink-Reasoning", - "The remaining risk should be decomposed and prioritized before more work is added.", - f"Break the remaining delivery gaps into a tight remediation plan for this goal:\n\n{goal}", - "medium", - ) - - if doc_goal and not has_artifact: - add( - "Artifact / Drafter", - "A polished deliverable should be consolidated into a durable artifact before release.", - f"Draft or refine the final artifact for this goal:\n\n{goal}", - "medium", - ) - - if not recommendations: - add( - "Graphlink-Reasoning", - "A final critique pass is the safest next move when the branch is close but not yet proven.", - f"Review the remaining gaps and prioritize the smallest set of changes needed to finish this goal:\n\n{goal}", - "medium", - ) - - next_actions = [] - if blockers: - next_actions.extend(blockers[:2]) - else: - next_actions.append("Preserve the current branch, then close only the highest-risk remaining gap.") - - for item in recommendations[:3]: - next_actions.append(f"Use {item['plugin']} next: {item['why']}") - - next_actions = next_actions[:4] - - if verdict == "ready": - release_decision = "Ready for production-style use, but keep one final pass for regression awareness." - elif verdict == "blocked": - release_decision = "Do not ship yet. The branch still lacks essential proof or contains unresolved failure signals." - else: - release_decision = "Promising, but not ready to ship yet. Close the highlighted gaps first." - - note_summary_lines = [ - "# Quality Gate Summary", - "", - f"- Verdict: {verdict.replace('_', ' ').title()}", - f"- Readiness Score: {score} / 100", - f"- Release Decision: {release_decision}", - ] - if blockers: - note_summary_lines.append(f"- Top blocker: {blockers[0]}") - if missing_evidence: - note_summary_lines.append(f"- Missing evidence: {missing_evidence[0]}") - - return { - "title": "Quality Gate Review", - "verdict": verdict, - "readiness_score": score, - "overview": "This branch has momentum, but production-level confidence depends on evidence, verification, and an explicit acceptance bar rather than good intentions alone.", - "strengths": strengths[:5], - "blockers": blockers[:5], - "risks": risks[:5] or ["The branch should stay focused on the highest-risk gap instead of widening scope."], - "missing_evidence": missing_evidence[:5] or ["A final high-confidence review pass after the next material change would strengthen confidence."], - "recommended_plugins": recommendations[:4], - "next_actions": next_actions, - "release_decision": release_decision, - "note_summary": "\n".join(note_summary_lines), - } - - def _normalize_review(self, review, goal, criteria, payload): - if not isinstance(review, dict): - review = {} - - normalized_recommendations = [] - for item in review.get("recommended_plugins", [])[:4]: - if not isinstance(item, dict): - continue - plugin = str(item.get("plugin", "")).strip() - if plugin not in QUALITY_GATE_ALLOWED_PLUGINS: - continue - why = str(item.get("why", "")).strip() or "This plugin closes an important remaining readiness gap." - starter_prompt = str(item.get("starter_prompt", "")).strip() or goal - priority = str(item.get("priority", "medium")).strip().lower() - if priority not in {"high", "medium", "low"}: - priority = "medium" - normalized_recommendations.append({ - "plugin": plugin, - "priority": priority, - "why": why, - "starter_prompt": starter_prompt, - }) - - def normalize_list(key, fallback_items): - items = [] - for value in review.get(key, []): - text = str(value).strip() - if text: - items.append(text) - return items or fallback_items - - verdict = str(review.get("verdict", "needs_work")).strip().lower() - if verdict not in {"ready", "needs_work", "blocked"}: - verdict = "needs_work" - - try: - readiness_score = int(review.get("readiness_score", 60)) - except (TypeError, ValueError): - readiness_score = 60 - readiness_score = max(0, min(100, readiness_score)) - - normalized = { - "title": str(review.get("title", "")).strip() or "Quality Gate Review", - "verdict": verdict, - "readiness_score": readiness_score, - "overview": str(review.get("overview", "")).strip() or "This branch needs a sharper acceptance review before it can be treated as release-ready.", - "strengths": normalize_list("strengths", ["The branch contains enough structure to support a serious review pass."]), - "blockers": normalize_list("blockers", ["No critical blockers were identified."]) if verdict == "ready" else normalize_list("blockers", ["The branch still has unresolved gaps before release."]), - "risks": normalize_list("risks", ["Remaining risk is manageable if the next changes stay tightly scoped."]), - "missing_evidence": normalize_list("missing_evidence", ["No major evidence gaps were identified."]) if verdict == "ready" else normalize_list("missing_evidence", ["A stronger evidence trail is still needed before release."]), - "recommended_plugins": normalized_recommendations, - "next_actions": normalize_list("next_actions", ["Take the next highest-leverage step and rerun Quality Gate after a material change."]), - "release_decision": str(review.get("release_decision", "")).strip() or "Not ready to ship yet.", - "note_summary": str(review.get("note_summary", "")).strip(), - } - - if not normalized["note_summary"]: - normalized["note_summary"] = "\n".join([ - "# Quality Gate Summary", - "", - f"- Branch: {payload.get('label', 'Current Branch')}", - f"- Verdict: {normalized['verdict'].replace('_', ' ').title()}", - f"- Readiness Score: {normalized['readiness_score']} / 100", - f"- Release Decision: {normalized['release_decision']}", - ]) - - return normalized - - def _build_markdown(self, review, payload): - lines = [ - f"# {review['title']}", - "", - "## Reviewed Branch", - f"- **Source:** {payload.get('label', 'Current Branch')}", - f"- **Branch Depth:** {payload.get('depth', 0)} step(s)", - "", - "## Verdict", - f"- **Verdict:** {review['verdict'].replace('_', ' ').title()}", - f"- **Readiness Score:** {review['readiness_score']} / 100", - f"- **Release Decision:** {review['release_decision']}", - "", - "## Overview", - review["overview"], - "", - "## Strengths", - ] - - for item in review["strengths"]: - lines.append(f"- {item}") - - lines.extend(["", "## Blockers"]) - for item in review["blockers"]: - lines.append(f"- {item}") - - lines.extend(["", "## Risks"]) - for item in review["risks"]: - lines.append(f"- {item}") - - lines.extend(["", "## Missing Evidence"]) - for item in review["missing_evidence"]: - lines.append(f"- {item}") - - lines.extend(["", "## Recommended Fix Paths"]) - if review["recommended_plugins"]: - for item in review["recommended_plugins"]: - lines.append(f"- **{item['plugin']}** ({item['priority'].title()}): {item['why']}") - lines.append(f" Starter prompt: `{item['starter_prompt']}`") - else: - lines.append("- No additional plugin path was required.") - - lines.extend(["", "## Next Actions"]) - for index, step in enumerate(review["next_actions"], start=1): - lines.append(f"{index}. {step}") - - return "\n".join(lines) - - def get_response(self, goal, criteria, payload): - user_prompt = f""" -Goal: -{goal} - -Acceptance Criteria: -{criteria if criteria.strip() else "None provided."} - -Reviewed Branch: -{payload.get('label', 'Current Branch')} - -Branch Steps: -{', '.join(payload.get('node_labels', [])) or 'No branch steps captured.'} - -Branch Transcript: -{payload.get('transcript', 'No branch transcript available.')} -""" - - try: - parsed = call_llm_and_parse_json(self.SYSTEM_PROMPT, user_prompt, task=config.TASK_CHAT) - normalized = self._normalize_review(parsed, goal, criteria, payload) - except Exception: - normalized = self._fallback_review(goal, criteria, payload) - - normalized["review_markdown"] = self._build_markdown(normalized, payload) - return normalized diff --git a/graphite_app/graphite_plugins/reasoning/__init__.py b/graphite_app/graphite_plugins/reasoning/__init__.py deleted file mode 100644 index 5ec79ef..0000000 --- a/graphite_app/graphite_plugins/reasoning/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Qt-free Graphlink-Reasoning agent package (see doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.10).""" diff --git a/graphite_app/graphite_plugins/reasoning/agent.py b/graphite_app/graphite_plugins/reasoning/agent.py deleted file mode 100644 index 8b507a6..0000000 --- a/graphite_app/graphite_plugins/reasoning/agent.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Graphlink-Reasoning's agent, redesigned from a 4-raw-text-prompt, up-to-22-call -pipeline into 1 (plan) + budget x 1 (merged reason+critique) + 1 (synthesize) structured -JSON calls (see doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.10). - -Follows the structured-JSON-agent pattern already proven in this codebase -(QualityGateAnalyzer, CodeReviewAnalyzer, WorkflowArchitectAgent): a system prompt asking -for an exact JSON shape, a _normalize_X method that defensively coerces every field with -typed fallbacks, and a deterministic _fallback_X heuristic used when the JSON shape is -unusable. - -Deliberately DIFFERENT from those three in one respect: a genuine api_provider.chat() -failure (network down, model unavailable) is allowed to propagate and abort the whole -run, exactly like the original run_step() did - only a malformed/unparseable JSON SHAPE -falls back to a heuristic and continues. For a single-call plugin, falling back to a -heuristic on total infra failure is a fine trade (the user still gets something useful). -For this multi-call pipeline, silently degrading every one of up to 10 steps to -placeholder text - with the final synthesis built entirely from placeholders and zero -indication anything was wrong - would be a worse experience than just surfacing the real -error once, immediately. -""" - -import json - -import graphite_config as config -import api_provider -from graphite_plugins.common.llm_json import extract_json_object - - -MAX_PLAN_STEPS = 12 -MAX_THOUGHT_HISTORY_ENTRIES = 8 -MAX_THOUGHT_HISTORY_CHARS_PER_ENTRY = 1200 - - -def _clean_text(value, limit=None): - text = str(value or "").strip() - if limit and len(text) > limit: - return text[: limit - 3].rstrip() + "..." - return text - - -def _truncate_thought_history(thought_history): - # Bounds thought_history growth across a long run instead of letting it grow - # unboundedly into every subsequent prompt (mirrors WorkflowArchitectAgent's - # get_response: history[-8:] plus a per-message character cap). - recent = list(thought_history)[-MAX_THOUGHT_HISTORY_ENTRIES:] - cleaned = [_clean_text(entry, limit=MAX_THOUGHT_HISTORY_CHARS_PER_ENTRY) for entry in recent] - cleaned = [entry for entry in cleaned if entry] - return "\n\n".join(cleaned) if cleaned else "No thoughts yet." - - -class ReasoningAgent: - """ - An agent that uses a multi-step "plan, reason+critique, synthesize" process to solve - complex problems that a single LLM call might fail on. - """ - - PLAN_SYSTEM_PROMPT = """ -You are a methodical planner. Your task is to break down a complex user query into a -series of simple, actionable steps that build towards a final answer. - -Rules: -1. Each step should represent a single, self-contained thought process or question. -2. Do NOT attempt to answer the query yet - only plan the steps. -3. The plan should logically flow from one step to the next. -4. The final step should always be to synthesize the previous steps into a final answer. -5. Output valid JSON only. No markdown fences, no preamble. - -Return exactly this shape: -{ - "steps": [ - {"title": "Short imperative step title", "goal": "What this step must accomplish"} - ] -} -""" - - REASON_CRITIQUE_SYSTEM_PROMPT = """ -You are a reasoning engine executing one step of a larger plan, and then critiquing your -own work. - -You will be given the branch context, the original query, the thought history from -previous steps, and the current step to execute. - -Rules: -1. Focus EXCLUSIVELY on the CURRENT step - do not solve the entire problem. -2. First produce a detailed, self-contained initial answer for the current step. -3. Then critique that initial answer: check for logical fallacies, missing information, - incorrect assumptions, or a simpler explanation. -4. Then produce a refined thought that incorporates the critique - more robust, logical, - and complete than the initial answer. -5. Output valid JSON only. No markdown fences, no preamble. - -Return exactly this shape: -{ - "initial_thought": "The first-pass answer to this step", - "critique": "Brief bulleted self-critique of the initial thought", - "refined_thought": "The improved, final thought for this step" -} -""" - - SYNTHESIZE_SYSTEM_PROMPT = """ -You are a synthesis expert. You have been provided with branch context, a user's -original query, and a series of vetted, refined thoughts that break down the problem. - -Rules: -- Weave these thoughts together into a single, comprehensive, well-structured final answer. -- Do not just list the thoughts; synthesize them into a coherent narrative that directly - addresses the user's original query. -- Use clear markdown formatting (headings, lists, bold text) for readability. -- The final answer should be self-contained and understandable without needing to read - the intermediate thoughts. -""" - - def _fallback_plan(self, original_prompt): - return { - "steps": [ - { - "title": "Analyze the query", - "goal": original_prompt or "Answer the user's question as directly as possible.", - } - ] - } - - def _normalize_plan(self, parsed, original_prompt): - if not isinstance(parsed, dict): - return self._fallback_plan(original_prompt) - - raw_steps = parsed.get("steps") - if not isinstance(raw_steps, list): - return self._fallback_plan(original_prompt) - - steps = [] - for item in raw_steps[:MAX_PLAN_STEPS]: - if not isinstance(item, dict): - continue - title = _clean_text(item.get("title"), limit=120) - goal = _clean_text(item.get("goal"), limit=500) - if not title and not goal: - continue - steps.append({"title": title or "Step", "goal": goal or title}) - - if not steps: - return self._fallback_plan(original_prompt) - - return {"steps": steps} - - def plan(self, original_prompt, branch_context): - user_prompt = f""" -Branch Context: -{branch_context or "No prior branch context."} - -Original Query: -{original_prompt} - -Create a step-by-step plan to answer this query. -""" - try: - response = api_provider.chat( - task=config.TASK_CHAT, - messages=[ - {"role": "system", "content": self.PLAN_SYSTEM_PROMPT}, - {"role": "user", "content": user_prompt}, - ], - ) - except Exception as exc: - raise RuntimeError(f"API call failed while planning: {exc}") from exc - - try: - parsed = json.loads(extract_json_object(response["message"]["content"])) - except Exception: - parsed = None - - return self._normalize_plan(parsed, original_prompt) - - def _fallback_step_result(self, step): - placeholder = ( - "The model's response for this step could not be used, so its planned goal " - f"is being carried forward as-is:\n\n{step.get('goal', '')}" - ) - return { - "initial_thought": placeholder, - "critique": "No critique available - the model response for this step was unusable.", - "refined_thought": placeholder, - } - - def _normalize_step_result(self, parsed): - if not isinstance(parsed, dict): - return None - - initial_thought = _clean_text(parsed.get("initial_thought")) - critique = _clean_text(parsed.get("critique")) - # A missing/empty "refined thought" falls back to the initial thought specifically - # - never to the raw parsed dict or a hunted-for substring of unrelated text (the - # structural fix for the old critique-parsing pollution bug). - refined_thought = _clean_text(parsed.get("refined_thought")) or initial_thought - - if not initial_thought and not refined_thought: - return None - - return { - "initial_thought": initial_thought or refined_thought, - "critique": critique or "No specific issues identified.", - "refined_thought": refined_thought or initial_thought, - } - - def reason_and_critique(self, original_prompt, branch_context, step, thought_history): - history_text = _truncate_thought_history(thought_history) - user_prompt = f""" -Branch Context: -{branch_context or "No prior branch context."} - -Original Query: -{original_prompt} - -Thought History: -{history_text} - -CURRENT STEP TO EXECUTE: -Title: {step.get('title', '')} -Goal: {step.get('goal', '')} -""" - try: - response = api_provider.chat( - task=config.TASK_CHAT, - messages=[ - {"role": "system", "content": self.REASON_CRITIQUE_SYSTEM_PROMPT}, - {"role": "user", "content": user_prompt}, - ], - ) - except Exception as exc: - raise RuntimeError(f"API call failed during step '{step.get('title', '')}': {exc}") from exc - - try: - parsed = json.loads(extract_json_object(response["message"]["content"])) - except Exception: - parsed = None - - normalized = self._normalize_step_result(parsed) - if normalized is None: - return self._fallback_step_result(step) - return normalized - - def synthesize(self, original_prompt, branch_context, thought_history): - history_text = _truncate_thought_history(thought_history) - user_prompt = f""" -Branch Context: -{branch_context or "No prior branch context."} - -Original Query: -{original_prompt} - -Full History of Refined Thoughts: -{history_text} -""" - try: - response = api_provider.chat( - task=config.TASK_CHAT, - messages=[ - {"role": "system", "content": self.SYNTHESIZE_SYSTEM_PROMPT}, - {"role": "user", "content": user_prompt}, - ], - ) - return response["message"]["content"] - except Exception as exc: - raise RuntimeError(f"API call failed during synthesis: {exc}") from exc diff --git a/graphite_app/graphite_scene.py b/graphite_app/graphite_scene.py index e85622e..b1b1894 100644 --- a/graphite_app/graphite_scene.py +++ b/graphite_app/graphite_scene.py @@ -16,13 +16,8 @@ from graphite_plugins.graphite_plugin_code_sandbox import CodeSandboxNode from graphite_web import WebNode, WebConnectionItem from graphite_conversation_node import ConversationNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningNode, ReasoningConnectionItem from graphite_html_view import HtmlViewNode from graphite_plugins.graphite_plugin_artifact import ArtifactNode, ArtifactConnectionItem -from graphite_plugins.graphite_plugin_workflow import WorkflowNode, WorkflowConnectionItem -from graphite_plugins.graphite_plugin_graph_diff import GraphDiffNode, GraphDiffConnectionItem -from graphite_plugins.graphite_plugin_quality_gate import QualityGateNode, QualityGateConnectionItem -from graphite_plugins.graphite_plugin_code_review import CodeReviewNode, CodeReviewConnectionItem from graphite_plugins.graphite_plugin_gitlink import GitlinkNode, GitlinkConnectionItem from graphite_memory import clone_history, resolve_branch_parent @@ -63,13 +58,8 @@ def __init__(self, window): self.code_sandbox_nodes = [] self.web_nodes = [] self.conversation_nodes = [] - self.reasoning_nodes = [] self.html_view_nodes = [] self.artifact_nodes = [] - self.workflow_nodes = [] - self.graph_diff_nodes = [] - self.quality_gate_nodes = [] - self.code_review_nodes = [] self.gitlink_nodes = [] self.chart_nodes = [] self.transient_layout_items = [] @@ -83,14 +73,9 @@ def __init__(self, window): self.code_sandbox_connections = [] self.web_connections = [] self.conversation_connections = [] - self.reasoning_connections = [] self.group_summary_connections = [] self.html_connections = [] self.artifact_connections = [] - self.workflow_connections = [] - self.graph_diff_connections = [] - self.quality_gate_connections = [] - self.code_review_connections = [] self.gitlink_connections = [] self.setBackgroundBrush(QColor("#252526")) @@ -182,14 +167,9 @@ def _all_connection_lists(self): self.code_sandbox_connections, self.web_connections, self.conversation_connections, - self.reasoning_connections, self.group_summary_connections, self.html_connections, self.artifact_connections, - self.workflow_connections, - self.graph_diff_connections, - self.quality_gate_connections, - self.code_review_connections, self.gitlink_connections, ] @@ -252,18 +232,8 @@ def find_items(self, text): content = node.thinking_text elif isinstance(node, ConversationNode): content = "\n".join([msg.get('content', '') for msg in node.conversation_history]) - elif isinstance(node, ReasoningNode): - content = node.prompt + "\n" + node.thought_process elif isinstance(node, ArtifactNode): content = node.get_artifact_content() + "\n" + node.chat_html_cache - elif isinstance(node, WorkflowNode): - content = node.blueprint_markdown + "\n" + node.get_goal() + "\n" + node.get_constraints() - elif isinstance(node, GraphDiffNode): - content = node.comparison_markdown + "\n" + node.note_summary - elif isinstance(node, QualityGateNode): - content = node.review_markdown + "\n" + node.note_summary + "\n" + node.get_goal() + "\n" + node.get_criteria() - elif isinstance(node, CodeReviewNode): - content = node.review_markdown + "\n" + node.get_review_context() + "\n" + node.source_editor.toPlainText() elif isinstance(node, GitlinkNode): content = node.get_task_prompt() + "\n" + node.context_xml + "\n" + node.proposal_markdown + "\n" + node.preview_text elif isinstance(node, PyCoderNode): @@ -518,8 +488,8 @@ def nodeMoved(self, node): all_connection_lists = [ self.connections, self.content_connections, self.document_connections, self.image_connections, self.thinking_connections, self.system_prompt_connections, self.pycoder_connections, self.code_sandbox_connections, self.web_connections, - self.conversation_connections, self.reasoning_connections, self.group_summary_connections, - self.html_connections, self.artifact_connections, self.workflow_connections, self.graph_diff_connections, self.quality_gate_connections, self.code_review_connections, self.gitlink_connections + self.conversation_connections, self.group_summary_connections, + self.html_connections, self.artifact_connections, self.gitlink_connections ] for conn_list in all_connection_lists: @@ -558,7 +528,7 @@ def add_chart(self, data, pos, parent_content_node=None): def createFrame(self): """Creates a Frame around the currently selected nodes.""" selected_nodes = [item for item in self.selectedItems() - if isinstance(item, (ChatNode, CodeNode, DocumentNode, ImageNode, ThinkingNode, ChartItem, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, HtmlViewNode, ArtifactNode, WorkflowNode, GraphDiffNode, QualityGateNode, CodeReviewNode, GitlinkNode))] + if isinstance(item, (ChatNode, CodeNode, DocumentNode, ImageNode, ThinkingNode, ChartItem, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, HtmlViewNode, ArtifactNode, GitlinkNode))] if not selected_nodes: return @@ -593,7 +563,7 @@ def createFrame(self): def createContainer(self): """Creates a Container around the currently selected items.""" selected_items = [item for item in self.selectedItems() - if isinstance(item, (ChatNode, CodeNode, DocumentNode, ImageNode, ThinkingNode, Note, ChartItem, Frame, Container, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, HtmlViewNode, ArtifactNode, WorkflowNode, GraphDiffNode, QualityGateNode, CodeReviewNode, GitlinkNode))] + if isinstance(item, (ChatNode, CodeNode, DocumentNode, ImageNode, ThinkingNode, Note, ChartItem, Frame, Container, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, HtmlViewNode, ArtifactNode, GitlinkNode))] if not selected_items: return @@ -700,8 +670,8 @@ def keyPressEvent(self, event): def _all_conversational_nodes(self): return ( self.nodes + self.pycoder_nodes + self.code_sandbox_nodes + self.web_nodes + - self.conversation_nodes + self.reasoning_nodes + self.html_view_nodes + - self.artifact_nodes + self.workflow_nodes + self.graph_diff_nodes + self.quality_gate_nodes + self.code_review_nodes + self.gitlink_nodes + self.conversation_nodes + self.html_view_nodes + + self.artifact_nodes + self.gitlink_nodes ) def _all_content_nodes(self): @@ -772,8 +742,7 @@ def unregister_transient_layout_item(self, item): def _spawn_clearance_for(self, item): conversational_types = ( ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, - ReasoningNode, HtmlViewNode, ArtifactNode, WorkflowNode, GraphDiffNode, - QualityGateNode, CodeReviewNode, GitlinkNode, + HtmlViewNode, ArtifactNode, GitlinkNode, ) content_types = (CodeNode, DocumentNode, ImageNode, ThinkingNode, ChartItem) @@ -947,8 +916,8 @@ def update_connections(self): # Update all other types of connections. for conn_list in [self.content_connections, self.document_connections, self.image_connections, self.thinking_connections, self.system_prompt_connections, self.pycoder_connections, self.code_sandbox_connections, self.web_connections, - self.conversation_connections, self.reasoning_connections, self.group_summary_connections, - self.html_connections, self.artifact_connections, self.workflow_connections, self.graph_diff_connections, self.quality_gate_connections, self.code_review_connections, self.gitlink_connections]: + self.conversation_connections, self.group_summary_connections, + self.html_connections, self.artifact_connections, self.gitlink_connections]: for conn in conn_list: conn.update_path() if hasattr(conn, 'sync_visibility_mode'): @@ -1202,7 +1171,6 @@ def delete_chat_node(self, node_to_delete): # Atomically remove all attached content nodes first. self.remove_associated_content_nodes(node_to_delete) - self._remove_graph_diffs_for_source(node_to_delete) children, parent_node = node_to_delete.children[:], node_to_delete.parent_node @@ -1248,33 +1216,6 @@ def delete_chat_node(self, node_to_delete): except Exception as e: QMessageBox.critical(None, "Error", f"An error occurred while deleting the node: {str(e)}") - def _delete_graph_diff_node(self, diff_node): - if not diff_node: - return - - for conn in self.graph_diff_connections[:]: - if diff_node in (conn.start_node, conn.end_node): - if conn.scene() == self: - self.removeItem(conn) - self.graph_diff_connections.remove(conn) - - if hasattr(diff_node, "dispose"): - diff_node.dispose() - - if diff_node.scene() == self: - self.removeItem(diff_node) - if diff_node in self.graph_diff_nodes: - self.graph_diff_nodes.remove(diff_node) - if self.window and self.window.current_node == diff_node: - self.window.current_node = None - self.window.message_input.setPlaceholderText("Type your message...") - - def _remove_graph_diffs_for_source(self, source_node): - for diff_node in self.graph_diff_nodes[:]: - if source_node in (getattr(diff_node, 'left_source_node', None), getattr(diff_node, 'right_source_node', None)): - self._delete_graph_diff_node(diff_node) - - def deleteSelectedItems(self): """ Deletes all currently selected items, handling each type appropriately. @@ -1303,7 +1244,6 @@ def deleteSelectedItems(self): if item in self.thinking_nodes: self.thinking_nodes.remove(item) elif isinstance(item, PyCoderNode): self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) self._remove_connections_for_node(item) if hasattr(item, "dispose"): item.dispose() @@ -1311,7 +1251,6 @@ def deleteSelectedItems(self): if item in self.pycoder_nodes: self.pycoder_nodes.remove(item) elif isinstance(item, CodeSandboxNode): self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) self._remove_connections_for_node(item) if hasattr(item, "dispose"): item.dispose() @@ -1319,71 +1258,30 @@ def deleteSelectedItems(self): if item in self.code_sandbox_nodes: self.code_sandbox_nodes.remove(item) elif isinstance(item, WebNode): self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) self._remove_connections_for_node(item) self.removeItem(item) if item in self.web_nodes: self.web_nodes.remove(item) elif isinstance(item, ConversationNode): self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) self._remove_connections_for_node(item) self.removeItem(item) if item in self.conversation_nodes: self.conversation_nodes.remove(item) - elif isinstance(item, ReasoningNode): - self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) - if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) - self._remove_connections_for_node(item) - self.removeItem(item) - if item in self.reasoning_nodes: self.reasoning_nodes.remove(item) elif isinstance(item, HtmlViewNode): self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) self._remove_connections_for_node(item) self.removeItem(item) if item in self.html_view_nodes: self.html_view_nodes.remove(item) elif isinstance(item, ArtifactNode): self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) self._remove_connections_for_node(item) self.removeItem(item) if item in self.artifact_nodes: self.artifact_nodes.remove(item) - elif isinstance(item, WorkflowNode): - self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) - if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) - self._remove_connections_for_node(item) - self.removeItem(item) - if item in self.workflow_nodes: self.workflow_nodes.remove(item) - elif isinstance(item, QualityGateNode): - self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) - if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) - self._remove_connections_for_node(item) - if hasattr(item, "dispose"): item.dispose() - self.removeItem(item) - if item in self.quality_gate_nodes: self.quality_gate_nodes.remove(item) - if self.window and self.window.current_node == item: - self.window.current_node = None - self.window.message_input.setPlaceholderText("Type your message...") - elif isinstance(item, CodeReviewNode): - self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) - if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) - self._remove_connections_for_node(item) - if hasattr(item, "dispose"): item.dispose() - self.removeItem(item) - if item in self.code_review_nodes: self.code_review_nodes.remove(item) - if self.window and self.window.current_node == item: - self.window.current_node = None - self.window.message_input.setPlaceholderText("Type your message...") elif isinstance(item, GitlinkNode): self._remove_associated_chart_nodes(item) - self._remove_graph_diffs_for_source(item) if item.parent_node and item in item.parent_node.children: item.parent_node.children.remove(item) self._remove_connections_for_node(item) if hasattr(item, "dispose"): item.dispose() @@ -1392,9 +1290,6 @@ def deleteSelectedItems(self): if self.window and self.window.current_node == item: self.window.current_node = None self.window.message_input.setPlaceholderText("Type your message...") - elif isinstance(item, GraphDiffNode): - self._remove_associated_chart_nodes(item) - self._delete_graph_diff_node(item) elif isinstance(item, Frame): self.deleteFrame(item) elif isinstance(item, Container): self.deleteContainer(item) elif isinstance(item, Note): @@ -1461,7 +1356,7 @@ def _calculate_smart_guide_snap(self, moving_item, new_pos): 'h_top': moving_rect.top(), 'h_middle': moving_rect.center().y(), 'h_bottom': moving_rect.bottom(), } - static_items = [item for item in self.items() if isinstance(item, (ChatNode, CodeNode, Note, Frame, ChartItem, DocumentNode, ImageNode, ThinkingNode, Container, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, HtmlViewNode, ArtifactNode, WorkflowNode, GraphDiffNode, QualityGateNode, CodeReviewNode, GitlinkNode)) and item != moving_item and not item.isSelected()] + static_items = [item for item in self.items() if isinstance(item, (ChatNode, CodeNode, Note, Frame, ChartItem, DocumentNode, ImageNode, ThinkingNode, Container, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, HtmlViewNode, ArtifactNode, GitlinkNode)) and item != moving_item and not item.isSelected()] for static_item in static_items: static_rect = static_item.sceneBoundingRect() @@ -1558,13 +1453,8 @@ def clear(self): self.code_sandbox_nodes.clear() self.web_nodes.clear() self.conversation_nodes.clear() - self.reasoning_nodes.clear() self.html_view_nodes.clear() self.artifact_nodes.clear() - self.workflow_nodes.clear() - self.graph_diff_nodes.clear() - self.quality_gate_nodes.clear() - self.code_review_nodes.clear() self.gitlink_nodes.clear() self.chart_nodes.clear() @@ -1577,14 +1467,9 @@ def clear(self): self.code_sandbox_connections.clear() self.web_connections.clear() self.conversation_connections.clear() - self.reasoning_connections.clear() self.group_summary_connections.clear() self.html_connections.clear() self.artifact_connections.clear() - self.workflow_connections.clear() - self.graph_diff_connections.clear() - self.quality_gate_connections.clear() - self.code_review_connections.clear() self.gitlink_connections.clear() if hasattr(self, 'window') and self.window: diff --git a/graphite_app/graphite_session/deserializers.py b/graphite_app/graphite_session/deserializers.py index 15a1d1e..bc92d63 100644 --- a/graphite_app/graphite_session/deserializers.py +++ b/graphite_app/graphite_session/deserializers.py @@ -20,13 +20,8 @@ from graphite_html_view import HtmlViewNode from graphite_node import ChatNode, CodeNode, DocumentNode, ImageNode, ThinkingNode from graphite_plugins.graphite_plugin_artifact import ArtifactConnectionItem, ArtifactNode -from graphite_plugins.graphite_plugin_code_review import CodeReviewConnectionItem, CodeReviewNode from graphite_plugins.graphite_plugin_code_sandbox import CodeSandboxConnectionItem, CodeSandboxNode from graphite_plugins.graphite_plugin_gitlink import GitlinkConnectionItem, GitlinkNode -from graphite_plugins.graphite_plugin_graph_diff import GraphDiffConnectionItem, GraphDiffNode -from graphite_plugins.graphite_plugin_quality_gate import QualityGateConnectionItem, QualityGateNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningConnectionItem, ReasoningNode -from graphite_plugins.graphite_plugin_workflow import WorkflowConnectionItem, WorkflowNode from graphite_pycoder import PyCoderMode, PyCoderNode from graphite_web import WebConnectionItem, WebNode @@ -150,11 +145,6 @@ def deserialize_conversation_connection(self, data, scene, all_nodes_map): data, scene, all_nodes_map, ConversationConnectionItem, "conversation_connections" ) - def deserialize_reasoning_connection(self, data, scene, all_nodes_map): - return self._deserialize_basic_connection( - data, scene, all_nodes_map, ReasoningConnectionItem, "reasoning_connections" - ) - def deserialize_html_connection(self, data, scene, all_nodes_map): return self._deserialize_basic_connection( data, scene, all_nodes_map, HtmlConnectionItem, "html_connections" @@ -165,21 +155,6 @@ def deserialize_artifact_connection(self, data, scene, all_nodes_map): data, scene, all_nodes_map, ArtifactConnectionItem, "artifact_connections" ) - def deserialize_workflow_connection(self, data, scene, all_nodes_map): - return self._deserialize_basic_connection( - data, scene, all_nodes_map, WorkflowConnectionItem, "workflow_connections" - ) - - def deserialize_quality_gate_connection(self, data, scene, all_nodes_map): - return self._deserialize_basic_connection( - data, scene, all_nodes_map, QualityGateConnectionItem, "quality_gate_connections" - ) - - def deserialize_code_review_connection(self, data, scene, all_nodes_map): - return self._deserialize_basic_connection( - data, scene, all_nodes_map, CodeReviewConnectionItem, "code_review_connections" - ) - def deserialize_gitlink_connection(self, data, scene, all_nodes_map): return self._deserialize_basic_connection( data, scene, all_nodes_map, GitlinkConnectionItem, "gitlink_connections" @@ -333,24 +308,6 @@ def deserialize_node(self, index, data, all_nodes_map): scene.addItem(node) scene.conversation_nodes.append(node) - elif node_type == "reasoning": - parent_node = all_nodes_map.get(data["parent_node_index"]) - if parent_node: - node = ReasoningNode(parent_node) - node.setPos(data["position"]["x"], data["position"]["y"]) - node.prompt_input.setText(data.get("prompt", "")) - node.budget_slider.setValue(data.get("thinking_budget", 3)) - node.thought_process_display.setMarkdown(data.get("thought_process", "")) - node.set_status(data.get("status", "Idle")) - node.conversation_history = deserialize_history(data.get("conversation_history", [])) - node.include_branch_context = data.get("include_branch_context", True) - self._connect_if_available(node.reasoning_requested, "execute_reasoning_node") - self._connect_if_available(node.stop_requested, "stop_reasoning_node") - if data.get("is_collapsed", False): - node.set_collapsed(True) - scene.addItem(node) - scene.reasoning_nodes.append(node) - elif node_type == "html": parent_node = all_nodes_map.get(data["parent_node_index"]) if parent_node: @@ -383,140 +340,6 @@ def deserialize_node(self, index, data, all_nodes_map): scene.addItem(node) scene.artifact_nodes.append(node) - elif node_type == "workflow": - parent_node = all_nodes_map.get(data["parent_node_index"]) - if parent_node: - node = WorkflowNode(parent_node) - node.setPos(data["position"]["x"], data["position"]["y"]) - node.goal_input.setPlainText(data.get("goal", "")) - node.constraints_input.setPlainText(data.get("constraints", "")) - node.conversation_history = deserialize_history(data.get("conversation_history", [])) - node.include_branch_context = data.get("include_branch_context", True) - node.blueprint_markdown = data.get("blueprint_markdown", "") - node.recommendations = data.get("recommendations", []) - node.status = data.get("status", "Idle") - if node.blueprint_markdown or node.recommendations: - node.set_plan( - { - "blueprint_markdown": node.blueprint_markdown, - "recommended_plugins": node.recommendations, - } - ) - else: - node.set_status(node.status) - if data.get("is_collapsed", False): - node.set_collapsed(True) - self._connect_if_available(node.workflow_requested, "execute_workflow_node") - self._connect_if_available(node.plugin_requested, "instantiate_seeded_plugin") - scene.addItem(node) - scene.workflow_nodes.append(node) - - elif node_type == "graph_diff": - left_source = all_nodes_map.get(data.get("left_source_index")) - right_source = all_nodes_map.get(data.get("right_source_index")) - if left_source and right_source: - node = GraphDiffNode(left_source, right_source) - node.setPos(data["position"]["x"], data["position"]["y"]) - node.comparison_markdown = data.get("comparison_markdown", "") - node.note_summary = data.get("note_summary", "") - node.status = data.get("status", "Idle") - if node.comparison_markdown: - node.set_result( - { - "comparison_markdown": node.comparison_markdown, - "note_summary": node.note_summary, - } - ) - else: - node.set_status(node.status) - if data.get("is_collapsed", False): - node.set_collapsed(True) - self._connect_if_available(node.compare_requested, "execute_graph_diff_node") - self._connect_if_available(node.note_requested, "create_graph_diff_note") - scene.addItem(node) - scene.graph_diff_nodes.append(node) - - for source_node in (left_source, right_source): - connection = GraphDiffConnectionItem(source_node, node) - scene.addItem(connection) - scene.graph_diff_connections.append(connection) - - elif node_type == "quality_gate": - parent_node = all_nodes_map.get(data["parent_node_index"]) - if parent_node: - node = QualityGateNode(parent_node) - node.setPos(data["position"]["x"], data["position"]["y"]) - node.goal_input.setPlainText(data.get("goal", "")) - node.criteria_input.setPlainText(data.get("criteria", "")) - node.conversation_history = deserialize_history(data.get("conversation_history", [])) - node.include_branch_context = data.get("include_branch_context", True) - node.review_markdown = data.get("review_markdown", "") - node.note_summary = data.get("note_summary", "") - node.recommendations = data.get("recommendations", []) - node.verdict = data.get("verdict", "pending") - node.readiness_score = data.get("readiness_score", 0) - node.status = data.get("status", "Idle") - if node.review_markdown or node.recommendations: - node.set_review( - { - "verdict": node.verdict, - "readiness_score": node.readiness_score, - "review_markdown": node.review_markdown, - "note_summary": node.note_summary, - "recommended_plugins": node.recommendations, - } - ) - else: - node.set_status(node.status) - if data.get("is_collapsed", False): - node.set_collapsed(True) - self._connect_if_available(node.review_requested, "execute_quality_gate_node") - self._connect_if_available(node.plugin_requested, "instantiate_seeded_plugin") - self._connect_if_available(node.note_requested, "create_quality_gate_note") - scene.addItem(node) - scene.quality_gate_nodes.append(node) - - elif node_type == "code_review": - parent_node = all_nodes_map.get(data["parent_node_index"]) - if parent_node: - node = CodeReviewNode(parent_node, settings_manager=getattr(self.window, "settings_manager", None)) - node.setPos(data["position"]["x"], data["position"]["y"]) - node.context_input.setPlainText(data.get("review_context", "")) - node._set_source_text( - data.get("source_text", ""), - data.get( - "source_state", - { - "origin": "", - "label": "", - "repo": "", - "branch": "", - "path": "", - "local_path": "", - "edited": False, - }, - ), - ) - node.conversation_history = deserialize_history(data.get("conversation_history", [])) - node.review_markdown = data.get("review_markdown", "") - node.review_data = data.get("review_data", {}) - node.verdict = data.get("verdict", "pending") - node.quality_score = data.get("quality_score", 0) - node.risk_level = data.get("risk_level", "unknown") - node.status = data.get("status", "Idle") - if node.review_data: - node.set_review(node.review_data) - elif node.review_markdown: - node.overview_display.setMarkdown(node.review_markdown) - node.set_status(node.status) - else: - node.set_status(node.status) - if data.get("is_collapsed", False): - node.set_collapsed(True) - self._connect_if_available(node.review_requested, "execute_code_review_node") - scene.addItem(node) - scene.code_review_nodes.append(node) - elif node_type == "gitlink": parent_node = all_nodes_map.get(data["parent_node_index"]) if parent_node: @@ -740,12 +563,8 @@ def restore_chat(self, chat, notes_data, pins_data): ("code_sandbox_connections", self.deserialize_code_sandbox_connection), ("web_connections", self.deserialize_web_connection), ("conversation_connections", self.deserialize_conversation_connection), - ("reasoning_connections", self.deserialize_reasoning_connection), ("html_connections", self.deserialize_html_connection), ("artifact_connections", self.deserialize_artifact_connection), - ("workflow_connections", self.deserialize_workflow_connection), - ("quality_gate_connections", self.deserialize_quality_gate_connection), - ("code_review_connections", self.deserialize_code_review_connection), ("gitlink_connections", self.deserialize_gitlink_connection), ) diff --git a/graphite_app/graphite_session/scene_index.py b/graphite_app/graphite_session/scene_index.py index d3c4e3a..cab1e37 100644 --- a/graphite_app/graphite_session/scene_index.py +++ b/graphite_app/graphite_session/scene_index.py @@ -3,13 +3,8 @@ from graphite_html_view import HtmlViewNode from graphite_node import ChatNode from graphite_plugins.graphite_plugin_artifact import ArtifactNode -from graphite_plugins.graphite_plugin_code_review import CodeReviewNode from graphite_plugins.graphite_plugin_code_sandbox import CodeSandboxNode from graphite_plugins.graphite_plugin_gitlink import GitlinkNode -from graphite_plugins.graphite_plugin_graph_diff import GraphDiffNode -from graphite_plugins.graphite_plugin_quality_gate import QualityGateNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningNode -from graphite_plugins.graphite_plugin_workflow import WorkflowNode from graphite_pycoder import PyCoderNode from graphite_web import WebNode @@ -23,29 +18,19 @@ "code_sandbox_nodes", "web_nodes", "conversation_nodes", - "reasoning_nodes", "html_view_nodes", "artifact_nodes", - "workflow_nodes", - "graph_diff_nodes", - "quality_gate_nodes", - "code_review_nodes", "gitlink_nodes", ) SAVE_GUARD_NODE_LIST_NAMES = ( "nodes", "conversation_nodes", - "reasoning_nodes", "artifact_nodes", - "workflow_nodes", "pycoder_nodes", "code_sandbox_nodes", "web_nodes", "html_view_nodes", - "quality_gate_nodes", - "code_review_nodes", - "graph_diff_nodes", "gitlink_nodes", ) @@ -55,13 +40,8 @@ CodeSandboxNode, WebNode, ConversationNode, - ReasoningNode, HtmlViewNode, ArtifactNode, - WorkflowNode, - GraphDiffNode, - QualityGateNode, - CodeReviewNode, GitlinkNode, ) diff --git a/graphite_app/graphite_session/serializers.py b/graphite_app/graphite_session/serializers.py index 1544793..fa6c8e6 100644 --- a/graphite_app/graphite_session/serializers.py +++ b/graphite_app/graphite_session/serializers.py @@ -2,13 +2,8 @@ from graphite_html_view import HtmlViewNode from graphite_node import ChatNode, CodeNode, DocumentNode, ImageNode, ThinkingNode from graphite_plugins.graphite_plugin_artifact import ArtifactNode -from graphite_plugins.graphite_plugin_code_review import CodeReviewNode from graphite_plugins.graphite_plugin_code_sandbox import CodeSandboxNode from graphite_plugins.graphite_plugin_gitlink import GitlinkNode -from graphite_plugins.graphite_plugin_graph_diff import GraphDiffNode -from graphite_plugins.graphite_plugin_quality_gate import QualityGateNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningNode -from graphite_plugins.graphite_plugin_workflow import WorkflowNode from graphite_pycoder import PyCoderNode from graphite_web import WebNode @@ -88,24 +83,12 @@ def serialize_web_connection(self, connection, all_nodes_list): def serialize_conversation_connection(self, connection, all_nodes_list): return self._serialize_basic_connection(connection, all_nodes_list) - def serialize_reasoning_connection(self, connection, all_nodes_list): - return self._serialize_basic_connection(connection, all_nodes_list) - def serialize_html_connection(self, connection, all_nodes_list): return self._serialize_basic_connection(connection, all_nodes_list) def serialize_artifact_connection(self, connection, all_nodes_list): return self._serialize_basic_connection(connection, all_nodes_list) - def serialize_workflow_connection(self, connection, all_nodes_list): - return self._serialize_basic_connection(connection, all_nodes_list) - - def serialize_quality_gate_connection(self, connection, all_nodes_list): - return self._serialize_basic_connection(connection, all_nodes_list) - - def serialize_code_review_connection(self, connection, all_nodes_list): - return self._serialize_basic_connection(connection, all_nodes_list) - def serialize_gitlink_connection(self, connection, all_nodes_list): return self._serialize_basic_connection(connection, all_nodes_list) @@ -224,20 +207,6 @@ def serialize_node(self, node, all_nodes_list=None): "parent_node_index": all_nodes_list.index(node.parent_node), "children_indices": [all_nodes_list.index(child) for child in node.children], } - if isinstance(node, ReasoningNode): - return { - "node_type": "reasoning", - "position": {"x": node.pos().x(), "y": node.pos().y()}, - "prompt": node.prompt, - "thinking_budget": node.thinking_budget, - "thought_process": node.thought_process, - "status": node.status, - "conversation_history": serialize_history(getattr(node, "conversation_history", [])), - "include_branch_context": getattr(node, "include_branch_context", True), - "is_collapsed": node.is_collapsed, - "parent_node_index": all_nodes_list.index(node.parent_node), - "children_indices": [all_nodes_list.index(child) for child in node.children], - } if isinstance(node, HtmlViewNode): return { "node_type": "html", @@ -263,69 +232,6 @@ def serialize_node(self, node, all_nodes_list=None): "parent_node_index": all_nodes_list.index(node.parent_node), "children_indices": [all_nodes_list.index(child) for child in node.children], } - if isinstance(node, WorkflowNode): - return { - "node_type": "workflow", - "position": {"x": node.pos().x(), "y": node.pos().y()}, - "goal": node.get_goal(), - "constraints": node.get_constraints(), - "status": node.status, - "blueprint_markdown": node.blueprint_markdown, - "recommendations": node.recommendations, - "conversation_history": serialize_history(getattr(node, "conversation_history", [])), - "include_branch_context": getattr(node, "include_branch_context", True), - "is_collapsed": node.is_collapsed, - "parent_node_index": all_nodes_list.index(node.parent_node), - "children_indices": [all_nodes_list.index(child) for child in node.children], - } - if isinstance(node, GraphDiffNode): - return { - "node_type": "graph_diff", - "position": {"x": node.pos().x(), "y": node.pos().y()}, - "status": node.status, - "comparison_markdown": node.comparison_markdown, - "note_summary": node.note_summary, - "left_source_index": all_nodes_list.index(node.left_source_node), - "right_source_index": all_nodes_list.index(node.right_source_node), - "is_collapsed": node.is_collapsed, - "children_indices": [], - } - if isinstance(node, QualityGateNode): - return { - "node_type": "quality_gate", - "position": {"x": node.pos().x(), "y": node.pos().y()}, - "goal": node.get_goal(), - "criteria": node.get_criteria(), - "status": node.status, - "verdict": node.verdict, - "readiness_score": node.readiness_score, - "review_markdown": node.review_markdown, - "note_summary": node.note_summary, - "recommendations": node.recommendations, - "conversation_history": serialize_history(getattr(node, "conversation_history", [])), - "include_branch_context": getattr(node, "include_branch_context", True), - "is_collapsed": node.is_collapsed, - "parent_node_index": all_nodes_list.index(node.parent_node), - "children_indices": [all_nodes_list.index(child) for child in node.children], - } - if isinstance(node, CodeReviewNode): - return { - "node_type": "code_review", - "position": {"x": node.pos().x(), "y": node.pos().y()}, - "review_context": node.get_review_context(), - "source_text": node.source_editor.toPlainText(), - "source_state": node.source_state, - "status": node.status, - "verdict": node.verdict, - "quality_score": node.quality_score, - "risk_level": node.risk_level, - "review_markdown": node.review_markdown, - "review_data": node.review_data, - "conversation_history": serialize_history(getattr(node, "conversation_history", [])), - "is_collapsed": node.is_collapsed, - "parent_node_index": all_nodes_list.index(node.parent_node), - "children_indices": [all_nodes_list.index(child) for child in node.children], - } if isinstance(node, GitlinkNode): return { "node_type": "gitlink", @@ -418,12 +324,8 @@ def serialize_chat_data(self): ("code_sandbox_connections", scene.code_sandbox_connections, self.serialize_code_sandbox_connection), ("web_connections", scene.web_connections, self.serialize_web_connection), ("conversation_connections", scene.conversation_connections, self.serialize_conversation_connection), - ("reasoning_connections", scene.reasoning_connections, self.serialize_reasoning_connection), ("html_connections", scene.html_connections, self.serialize_html_connection), ("artifact_connections", scene.artifact_connections, self.serialize_artifact_connection), - ("workflow_connections", scene.workflow_connections, self.serialize_workflow_connection), - ("quality_gate_connections", scene.quality_gate_connections, self.serialize_quality_gate_connection), - ("code_review_connections", scene.code_review_connections, self.serialize_code_review_connection), ("gitlink_connections", scene.gitlink_connections, self.serialize_gitlink_connection), ) diff --git a/graphite_app/graphite_ui_dialogs/graphite_system_dialogs.py b/graphite_app/graphite_ui_dialogs/graphite_system_dialogs.py index 5cb3ed4..2e631cf 100644 --- a/graphite_app/graphite_ui_dialogs/graphite_system_dialogs.py +++ b/graphite_app/graphite_ui_dialogs/graphite_system_dialogs.py @@ -305,17 +305,13 @@ class HelpDialog(QFrame): [ ("Research and Analysis", [ ("Graphlink-Web", "Use this when a branch depends on current information or external sources. The node runs a web retrieval flow, summarizes the findings, and stores source links directly in the result.", "fa5s.globe-americas"), - ("Graphlink-Reasoning", "Best for complex questions that benefit from staged thinking instead of a single direct answer. You can raise the thinking budget when a branch needs a slower, more deliberate analysis path.", "fa5s.brain"), ("Conversation Node", "Creates a self-contained linear chat surface inside the graph. Use it when you want a focused sub-conversation that can be pruned and iterated without expanding the main branch too aggressively.", "fa5s.comments"), - ("Branch Lens", "Compare two selected branch tips to see how their logic, code orientation, and intent differ. You can then turn the comparison into a note for easier project review.", "fa5s.code-branch"), - ("Quality Gate", "Runs a production-readiness review against the current branch, scores how close it is to done, calls out blockers and missing evidence, and can spawn the next best remediation nodes directly from the report.", "fa5s.check-circle"), ]), ("Build, Draft, and Render", [ ("System Prompt", "Adds a branch-scoped system prompt note that changes assistant behavior only for that conversation path. This is ideal when you want a role, tone, or instruction change without affecting the rest of the project.", "fa5s.sliders-h"), ("Py-Coder", "A coding workspace with AI-driven and manual modes, generated code, terminal output, and final analysis tabs. Use it for fast implementation, debugging, code generation, and lightweight computation.", "fa5s.laptop-code"), ("Execution Sandbox", "Runs Python in an isolated virtual environment and supports per-node requirements. Choose this over Py-Coder when you need dependency-aware execution or a cleaner reproducible runtime.", "fa5s.shield-alt"), ("Artifact / Drafter", "A living markdown drafting surface for reports, specs, briefs, and other documents that need repeated revision. It is the most natural place to keep polished long-form output inside the graph.", "fa5s.pen-nib"), - ("Workflow Architect", "Builds a compact execution blueprint for the current goal and recommends the next plugin nodes to create, complete with seeded starter prompts. It is especially useful at the start of a large or ambiguous task.", "fa5s.project-diagram"), ("HTML Renderer", "Turns HTML or UI markup into a preview pane inside the graph and can pop the preview into a separate window. It is the right tool when a branch needs visual feedback instead of plain text output.", "fa5s.window-maximize"), ]), ], diff --git a/graphite_app/graphite_window.py b/graphite_app/graphite_window.py index f8df0ba..d65e218 100644 --- a/graphite_app/graphite_window.py +++ b/graphite_app/graphite_window.py @@ -18,13 +18,8 @@ from graphite_plugins.graphite_plugin_code_sandbox import CodeSandboxNode from graphite_web import WebNode from graphite_conversation_node import ConversationNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningNode from graphite_html_view import HtmlViewNode from graphite_plugins.graphite_plugin_artifact import ArtifactNode -from graphite_plugins.graphite_plugin_workflow import WorkflowNode -from graphite_plugins.graphite_plugin_graph_diff import GraphDiffNode -from graphite_plugins.graphite_plugin_quality_gate import QualityGateNode -from graphite_plugins.graphite_plugin_code_review import CodeReviewNode from graphite_plugins.graphite_plugin_gitlink import GitlinkNode from graphite_library_dialog import ChatLibraryDialog @@ -732,12 +727,6 @@ def _extract_document_view_content(self, node): if isinstance(node, ConversationNode): return self._build_document_section("Conversation Transcript", self._history_to_markdown(getattr(node, "conversation_history", []))) - if isinstance(node, ReasoningNode): - return self._join_document_sections( - self._build_document_section("Prompt", getattr(node, "prompt", "")), - self._build_document_section("Reasoning Trace", getattr(node, "thought_process", "")), - ) - if isinstance(node, WebNode): sources = getattr(node, "sources", []) or [] source_lines = "\n".join(f"- [{url}]({url})" for url in sources if str(url).strip()) @@ -779,62 +768,6 @@ def _extract_document_view_content(self, node): self._build_document_section("Drafting Transcript", transcript), ) - if isinstance(node, WorkflowNode): - recommendations = [] - for item in getattr(node, "recommendations", []) or []: - plugin = str(item.get("plugin", "Plugin")).strip() - why = str(item.get("why", "")).strip() - priority = str(item.get("priority", "")).strip() - prompt = str(item.get("starter_prompt", "")).strip() - summary = f"- **{plugin}**" - if priority: - summary += f" ({priority})" - if why: - summary += f": {why}" - if prompt: - summary += f"\n Starter Prompt: {prompt}" - recommendations.append(summary) - return self._join_document_sections( - self._build_document_section("Goal", node.get_goal() if hasattr(node, "get_goal") else ""), - self._build_document_section("Constraints", node.get_constraints() if hasattr(node, "get_constraints") else ""), - self._build_document_section("Workflow Blueprint", getattr(node, "blueprint_markdown", "")), - self._build_document_section("Recommended Plugins", "\n".join(recommendations)), - ) - - if isinstance(node, GraphDiffNode): - return self._join_document_sections( - self._build_document_section("Branch Comparison", getattr(node, "comparison_markdown", "")), - self._build_document_section("Summary Note", getattr(node, "note_summary", "")), - ) - - if isinstance(node, QualityGateNode): - recommendations = [] - for item in getattr(node, "recommendations", []) or []: - plugin = str(item.get("plugin", "Plugin")).strip() - why = str(item.get("why", "")).strip() - starter_prompt = str(item.get("starter_prompt", "")).strip() - summary = f"- **{plugin}**" - if why: - summary += f": {why}" - if starter_prompt: - summary += f"\n Starter Prompt: {starter_prompt}" - recommendations.append(summary) - return self._join_document_sections( - self._build_document_section("Goal", node.get_goal() if hasattr(node, "get_goal") else ""), - self._build_document_section("Acceptance Criteria", node.get_criteria() if hasattr(node, "get_criteria") else ""), - self._build_document_section("Quality Review", getattr(node, "review_markdown", "")), - self._build_document_section("Recommended Plugins", "\n".join(recommendations)), - self._build_document_section("Summary Note", getattr(node, "note_summary", "")), - ) - - if isinstance(node, CodeReviewNode): - source_text = node.source_editor.toPlainText() if hasattr(node, "source_editor") else "" - return self._join_document_sections( - self._build_document_section("Review Context", node.get_review_context() if hasattr(node, "get_review_context") else ""), - self._build_document_section("Code Review", getattr(node, "review_markdown", "")), - self._build_document_section("Source Snapshot", self._build_code_block(source_text)), - ) - if isinstance(node, GitlinkNode): context_xml = self._truncate_document_text(getattr(node, "context_xml", "")) return self._join_document_sections( @@ -974,12 +907,7 @@ def setCurrentNode(self, node): elif isinstance(node, CodeSandboxNode): text_content = "Execution Sandbox" elif isinstance(node, WebNode): text_content = "Web Search Node" elif isinstance(node, ConversationNode): text_content = "Conversation" - elif isinstance(node, ReasoningNode): text_content = "Reasoning Node" elif isinstance(node, HtmlViewNode): text_content = "HTML Renderer" - elif isinstance(node, WorkflowNode): text_content = "Workflow Architect" - elif isinstance(node, GraphDiffNode): text_content = "Branch Lens" - elif isinstance(node, QualityGateNode): text_content = "Quality Gate" - elif isinstance(node, CodeReviewNode): text_content = "Code Review Agent" elif isinstance(node, GitlinkNode): text_content = "Gitlink" elif isinstance(node, Note) and node.is_summary_note: self.message_input.setPlaceholderText("Cannot respond to a summary note."); return if text_content: self.message_input.setPlaceholderText(f"Responding to: {text_content[:30]}...") @@ -1311,7 +1239,7 @@ def handle_error(self, error_message): self.message_input.setEnabled(True); self.send_button.setEnabled(True); self.attach_file_btn.setEnabled(True); self.clear_attachment() def _get_single_selected_node(self): - selected_items = self.chat_view.scene().selectedItems(); valid_types = (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, HtmlViewNode, WorkflowNode, GraphDiffNode, QualityGateNode, CodeReviewNode, GitlinkNode) + selected_items = self.chat_view.scene().selectedItems(); valid_types = (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, HtmlViewNode, GitlinkNode) if len(selected_items) == 1 and isinstance(selected_items[0], valid_types): return selected_items[0] return None diff --git a/graphite_app/graphite_window_actions.py b/graphite_app/graphite_window_actions.py index 7624a46..5d5d877 100644 --- a/graphite_app/graphite_window_actions.py +++ b/graphite_app/graphite_window_actions.py @@ -14,13 +14,8 @@ from graphite_plugins.graphite_plugin_code_sandbox import CodeSandboxNode from graphite_web import WebNode from graphite_conversation_node import ConversationNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningNode from graphite_html_view import HtmlViewNode from graphite_plugins.graphite_plugin_artifact import ArtifactNode -from graphite_plugins.graphite_plugin_workflow import WorkflowNode, WorkflowWorkerThread -from graphite_plugins.graphite_plugin_graph_diff import GraphDiffNode, GraphDiffWorkerThread -from graphite_plugins.graphite_plugin_quality_gate import QualityGateNode, QualityGateWorkerThread -from graphite_plugins.graphite_plugin_code_review import CodeReviewNode, CodeReviewWorkerThread from graphite_plugins.graphite_plugin_gitlink import GitlinkNode, GitlinkWorkerThread from graphite_config import get_current_palette from graphite_config import get_semantic_color @@ -1170,61 +1165,6 @@ def _handle_web_worker_error(self, error_message, node): if node and node.scene(): node.set_error(error_message); node.set_running_state(False) - def execute_reasoning_node(self, reasoning_node): - prompt = reasoning_node.prompt.strip() - if not prompt: - reasoning_node.set_error("Prompt cannot be empty."); return - reasoning_node.set_running_state(True); reasoning_node.clear_thoughts() - parent_history = self._branch_context_history(reasoning_node, reasoning_node.parent_node) - trimmed_parent_history, _ = trim_history( - parent_history, - self.token_estimator, - max_tokens=6000, - system_prompt_estimate=1200, - ) - branch_context = history_to_transcript(trimmed_parent_history, max_messages=8, max_chars_per_message=700) - - from graphite_plugins.graphite_plugin_reasoning import ReasoningWorkerThread - from graphite_plugins.reasoning.agent import ReasoningAgent - worker_thread = ReasoningWorkerThread( - ReasoningAgent(), - original_prompt=prompt, - budget=reasoning_node.thinking_budget, - branch_context=branch_context, - ) - reasoning_node.worker_thread = worker_thread - worker_thread.step_finished.connect(lambda title, text, node=reasoning_node: self._handle_reasoning_step(title, text, node)) - worker_thread.finished.connect(lambda answer, node=reasoning_node, history=parent_history: self._handle_reasoning_finished(answer, node, history)) - worker_thread.error.connect(lambda error, node=reasoning_node: self._handle_reasoning_error(error, node)) - worker_thread.finished.connect(lambda _answer, thread=worker_thread, node=reasoning_node: self._cleanup_reasoning_thread(thread, node)) - worker_thread.error.connect(lambda _error, thread=worker_thread, node=reasoning_node: self._cleanup_reasoning_thread(thread, node)) - worker_thread.start() - - def _cleanup_reasoning_thread(self, worker_thread, reasoning_node): - if reasoning_node and getattr(reasoning_node, "worker_thread", None) is worker_thread: - reasoning_node.worker_thread = None - worker_thread.deleteLater() - - def stop_reasoning_node(self, reasoning_node): - worker_thread = getattr(reasoning_node, "worker_thread", None) - if worker_thread and worker_thread.isRunning(): - worker_thread.stop() - reasoning_node.worker_thread = None - reasoning_node.append_thought("Stopped", "Reasoning manually stopped.") - reasoning_node.set_running_state(False) - - def _handle_reasoning_step(self, title, text, node): - if node and node.scene(): node.set_status(title); node.append_thought(title, text) - - def _handle_reasoning_finished(self, final_answer, node, parent_history): - if node and node.scene(): - node.set_final_answer(final_answer, parent_history=parent_history) - node.set_running_state(False) - self.save_chat() - - def _handle_reasoning_error(self, error_message, node): - if node and node.scene(): node.set_error(error_message) - def handle_conversation_node_request(self, requesting_node, history): requesting_node.set_typing(True) worker_thread = ChatWorkerThread(self.agent, history, requesting_node.parent_node) @@ -1393,177 +1333,6 @@ def _handle_artifact_error(self, error_msg, artifact_node): artifact_node.add_chat_message(f"Error: {error_msg}", is_user=False) artifact_node.set_running_state(False) - def execute_workflow_node(self, workflow_node): - goal = workflow_node.get_goal().strip() - constraints = workflow_node.get_constraints().strip() - if not goal: - workflow_node.set_error("Mission cannot be empty.") - return - - workflow_node.set_running_state(True) - parent_history = self._branch_context_history(workflow_node, workflow_node.parent_node) - trimmed_parent_history, _ = trim_history( - parent_history, - self.token_estimator, - max_tokens=6500, - system_prompt_estimate=1200, - ) - worker_thread = WorkflowWorkerThread(goal, constraints, trimmed_parent_history) - workflow_node.worker_thread = worker_thread - worker_thread.finished.connect(lambda result, node=workflow_node, history=parent_history: self._handle_workflow_result(result, node, history)) - worker_thread.error.connect(lambda error_msg, node=workflow_node: self._handle_workflow_error(error_msg, node)) - worker_thread.finished.connect(lambda _result, thread=worker_thread, node=workflow_node: self._cleanup_workflow_thread(thread, node)) - worker_thread.error.connect(lambda _error, thread=worker_thread, node=workflow_node: self._cleanup_workflow_thread(thread, node)) - worker_thread.start() - - def _cleanup_workflow_thread(self, worker_thread, workflow_node): - if workflow_node and getattr(workflow_node, "worker_thread", None) is worker_thread: - workflow_node.worker_thread = None - worker_thread.deleteLater() - - def stop_workflow_node(self, workflow_node): - worker_thread = getattr(workflow_node, "worker_thread", None) - if worker_thread and worker_thread.isRunning(): - worker_thread.stop() - workflow_node.worker_thread = None - workflow_node.set_error("Workflow planning manually stopped.") - workflow_node.set_running_state(False) - - def _handle_workflow_result(self, result, workflow_node, parent_history): - if not workflow_node or not workflow_node.scene(): - return - workflow_node.set_plan(result) - workflow_node.set_running_state(False) - - request_text = workflow_node.get_goal().strip() - constraints = workflow_node.get_constraints().strip() - if constraints: - request_text = f"{request_text}\n\nConstraints:\n{constraints}" - - assign_history(workflow_node, append_history(parent_history, [ - {'role': 'user', 'content': request_text}, - {'role': 'assistant', 'content': result.get('blueprint_markdown', '')} - ])) - - self.setCurrentNode(workflow_node) - self.save_chat() - - def _handle_workflow_error(self, error_message, workflow_node): - if not workflow_node or not workflow_node.scene(): - return - workflow_node.set_error(error_message) - workflow_node.set_running_state(False) - - def execute_quality_gate_node(self, quality_gate_node): - if not quality_gate_node or getattr(quality_gate_node, "is_disposed", False) or not quality_gate_node.scene(): - return - - goal = quality_gate_node.get_goal().strip() - criteria = quality_gate_node.get_criteria().strip() - if not goal: - quality_gate_node.set_error("Target outcome cannot be empty.") - return - - quality_gate_node.refresh_branch_context() - payload = quality_gate_node.get_review_payload() - quality_gate_node.set_running_state(True) - - worker_thread = QualityGateWorkerThread(goal, criteria, payload) - quality_gate_node.worker_thread = worker_thread - - worker_thread.finished.connect(lambda result, node=quality_gate_node: self._handle_quality_gate_result(result, node)) - worker_thread.error.connect(lambda error_msg, node=quality_gate_node: self._handle_quality_gate_error(error_msg, node)) - worker_thread.finished.connect(lambda _result, thread=worker_thread, node=quality_gate_node: self._cleanup_quality_gate_thread(thread, node)) - worker_thread.error.connect(lambda _error, thread=worker_thread, node=quality_gate_node: self._cleanup_quality_gate_thread(thread, node)) - worker_thread.start() - - def _cleanup_quality_gate_thread(self, worker_thread, quality_gate_node): - if quality_gate_node and getattr(quality_gate_node, "worker_thread", None) is worker_thread: - quality_gate_node.worker_thread = None - worker_thread.deleteLater() - - def _handle_quality_gate_result(self, result, quality_gate_node): - if not quality_gate_node or getattr(quality_gate_node, "is_disposed", False) or not quality_gate_node.scene(): - return - - quality_gate_node.set_review(result) - quality_gate_node.set_running_state(False) - - request_text = quality_gate_node.get_goal().strip() - criteria = quality_gate_node.get_criteria().strip() - if criteria: - request_text = f"{request_text}\n\nAcceptance Criteria:\n{criteria}" - - parent_history = self._branch_context_history(quality_gate_node, quality_gate_node.parent_node) - assign_history(quality_gate_node, append_history(parent_history, [ - {'role': 'user', 'content': request_text}, - {'role': 'assistant', 'content': result.get('review_markdown', '')} - ])) - - self.setCurrentNode(quality_gate_node) - self.save_chat() - - def _handle_quality_gate_error(self, error_message, quality_gate_node): - if not quality_gate_node or getattr(quality_gate_node, "is_disposed", False) or not quality_gate_node.scene(): - return - quality_gate_node.set_error(error_message) - quality_gate_node.set_running_state(False) - - def execute_code_review_node(self, code_review_node): - if not code_review_node or getattr(code_review_node, "is_disposed", False) or not code_review_node.scene(): - return - - payload = code_review_node.build_review_payload() - if not payload.get("source_text", "").strip(): - code_review_node.set_error("Load or paste source code before running the review.") - return - - code_review_node.refresh_github_state() - code_review_node.set_running_state(True) - - worker_thread = CodeReviewWorkerThread(payload) - code_review_node.worker_thread = worker_thread - - worker_thread.finished.connect(lambda result, node=code_review_node: self._handle_code_review_result(result, node)) - worker_thread.error.connect(lambda error_msg, node=code_review_node: self._handle_code_review_error(error_msg, node)) - worker_thread.finished.connect(lambda _result, thread=worker_thread, node=code_review_node: self._cleanup_code_review_thread(thread, node)) - worker_thread.error.connect(lambda _error, thread=worker_thread, node=code_review_node: self._cleanup_code_review_thread(thread, node)) - worker_thread.start() - - def _cleanup_code_review_thread(self, worker_thread, code_review_node): - if code_review_node and getattr(code_review_node, "worker_thread", None) is worker_thread: - code_review_node.worker_thread = None - worker_thread.deleteLater() - - def _handle_code_review_result(self, result, code_review_node): - if not code_review_node or getattr(code_review_node, "is_disposed", False) or not code_review_node.scene(): - return - - code_review_node.set_review(result) - code_review_node.set_running_state(False) - - source_state = getattr(code_review_node, "source_state", {}) or {} - source_label = source_state.get("path") or source_state.get("local_path") or source_state.get("label") or "loaded source" - request_text = f"Run deterministic code review for: {source_label}" - review_context = code_review_node.get_review_context().strip() - if review_context: - request_text = f"{request_text}\n\nContext:\n{review_context}" - - parent_history = get_node_history(code_review_node.parent_node) - assign_history(code_review_node, append_history(parent_history, [ - {'role': 'user', 'content': request_text}, - {'role': 'assistant', 'content': result.get('review_markdown', '')} - ])) - - self.setCurrentNode(code_review_node) - self.save_chat() - - def _handle_code_review_error(self, error_message, code_review_node): - if not code_review_node or getattr(code_review_node, "is_disposed", False) or not code_review_node.scene(): - return - code_review_node.set_error(error_message) - code_review_node.set_running_state(False) - def execute_gitlink_node(self, gitlink_node): if not gitlink_node or getattr(gitlink_node, "is_disposed", False) or not gitlink_node.scene(): return @@ -1628,73 +1397,6 @@ def _handle_gitlink_error(self, error_message, gitlink_node): gitlink_node.set_error(error_message) gitlink_node.set_running_state(False) - def execute_graph_diff_node(self, graph_diff_node): - if not graph_diff_node or getattr(graph_diff_node, "is_disposed", False) or not graph_diff_node.scene(): - return - - left_payload, right_payload = graph_diff_node.get_comparison_payloads() - graph_diff_node.set_running_state(True) - - worker_thread = GraphDiffWorkerThread(left_payload, right_payload) - graph_diff_node.worker_thread = worker_thread - - worker_thread.finished.connect(lambda result, node=graph_diff_node: self._handle_graph_diff_result(result, node)) - worker_thread.error.connect(lambda error_msg, node=graph_diff_node: self._handle_graph_diff_error(error_msg, node)) - worker_thread.finished.connect(lambda _result, thread=worker_thread, node=graph_diff_node: self._cleanup_graph_diff_thread(thread, node)) - worker_thread.error.connect(lambda _error, thread=worker_thread, node=graph_diff_node: self._cleanup_graph_diff_thread(thread, node)) - worker_thread.start() - - def _cleanup_graph_diff_thread(self, worker_thread, graph_diff_node): - if graph_diff_node and getattr(graph_diff_node, "worker_thread", None) is worker_thread: - graph_diff_node.worker_thread = None - worker_thread.deleteLater() - - def _handle_graph_diff_result(self, result, graph_diff_node): - if not graph_diff_node or getattr(graph_diff_node, "is_disposed", False) or not graph_diff_node.scene(): - return - graph_diff_node.set_result(result) - graph_diff_node.set_running_state(False) - self.setCurrentNode(graph_diff_node) - self.save_chat() - - def _handle_graph_diff_error(self, error_message, graph_diff_node): - if not graph_diff_node or getattr(graph_diff_node, "is_disposed", False) or not graph_diff_node.scene(): - return - graph_diff_node.set_error(error_message) - graph_diff_node.set_running_state(False) - - def create_graph_diff_note(self, graph_diff_node): - if not graph_diff_node or getattr(graph_diff_node, "is_disposed", False) or not graph_diff_node.scene(): - return - - scene = self.chat_view.scene() - note_pos = QPointF(graph_diff_node.pos().x() + graph_diff_node.width + 80, graph_diff_node.pos().y()) - note = scene.add_note(note_pos) - note.is_summary_note = True - note.content = graph_diff_node.note_summary or graph_diff_node.comparison_markdown or "Graph diff summary unavailable." - note.width = 340 - note.color = get_current_palette().FRAME_COLORS["Dark Gray"]["color"] - note.header_color = get_current_palette().FRAME_COLORS["Orange"]["color"] - if hasattr(note, "_recalculate_geometry"): - note._recalculate_geometry() - self.save_chat() - - def create_quality_gate_note(self, quality_gate_node): - if not quality_gate_node or getattr(quality_gate_node, "is_disposed", False) or not quality_gate_node.scene(): - return - - scene = self.chat_view.scene() - note_pos = QPointF(quality_gate_node.pos().x() + quality_gate_node.width + 80, quality_gate_node.pos().y()) - note = scene.add_note(note_pos) - note.is_summary_note = True - note.content = quality_gate_node.note_summary or quality_gate_node.review_markdown or "Quality gate summary unavailable." - note.width = 360 - note.color = get_current_palette().FRAME_COLORS["Dark Gray"]["color"] - note.header_color = get_current_palette().FRAME_COLORS["Yellow"]["color"] - if hasattr(note, "_recalculate_geometry"): - note._recalculate_geometry() - self.save_chat() - def instantiate_seeded_plugin(self, source_node, plugin_name, seed_prompt): previous_node = self.current_node self.current_node = source_node diff --git a/graphite_app/graphite_window_navigation.py b/graphite_app/graphite_window_navigation.py index bd1609c..725880b 100644 --- a/graphite_app/graphite_window_navigation.py +++ b/graphite_app/graphite_window_navigation.py @@ -5,18 +5,15 @@ from graphite_plugins.graphite_plugin_code_sandbox import CodeSandboxNode from graphite_web import WebNode from graphite_conversation_node import ConversationNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningNode from graphite_html_view import HtmlViewNode from graphite_plugins.graphite_plugin_artifact import ArtifactNode -from graphite_plugins.graphite_plugin_workflow import WorkflowNode -from graphite_plugins.graphite_plugin_quality_gate import QualityGateNode from graphite_plugins.graphite_plugin_gitlink import GitlinkNode from graphite_command_palette import CommandPaletteDialog class WindowNavigationMixin: def _setup_commands(self): self.command_manager.register_command("New Chat", ["start new", "clear session"], self.new_chat) - self.command_manager.register_command("Create Frame From Selection", ["group nodes", "frame selection"], self.chat_view.scene().createFrame, lambda: any(isinstance(item, (ChatNode, CodeNode, DocumentNode, ImageNode, ThinkingNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, HtmlViewNode, ArtifactNode, WorkflowNode, QualityGateNode, GitlinkNode)) for item in self.chat_view.scene().selectedItems())) + self.command_manager.register_command("Create Frame From Selection", ["group nodes", "frame selection"], self.chat_view.scene().createFrame, lambda: any(isinstance(item, (ChatNode, CodeNode, DocumentNode, ImageNode, ThinkingNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, HtmlViewNode, ArtifactNode, GitlinkNode)) for item in self.chat_view.scene().selectedItems())) self.command_manager.register_command("Create Container From Selection", ["group nodes", "container selection"], self.chat_view.scene().createContainer, lambda: bool(self.chat_view.scene().selectedItems())) self.command_manager.register_command("Collapse All Nodes", ["fold all"], self._cmd_collapse_all, lambda: bool(self.chat_view.scene()._all_conversational_nodes())) self.command_manager.register_command("Expand All Nodes", ["unfold all"], self._cmd_expand_all, lambda: bool(self.chat_view.scene()._all_conversational_nodes())) @@ -27,14 +24,11 @@ def _setup_commands(self): self.command_manager.register_command("Reset View", ["reset zoom", "default view"], self.chat_view.reset_zoom) self.command_manager.register_command("Focus on Selection", ["zoom to selection", "center selection"], self._cmd_focus_selection, lambda: bool(self.chat_view.scene().selectedItems())) self.command_manager.register_command("Add Note", ["create note", "new note"], self._cmd_add_note_center) - self.command_manager.register_command("Add Web Search Node", ["web search", "internet"], self.plugin_portal._create_web_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, WorkflowNode, ArtifactNode, QualityGateNode, GitlinkNode))) - self.command_manager.register_command("Add Reasoning Node", ["reasoning", "multi-step"], self.plugin_portal._create_reasoning_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, WorkflowNode, ArtifactNode, QualityGateNode, GitlinkNode))) - self.command_manager.register_command("Add HTML Renderer Node", ["render html", "html preview"], self.plugin_portal._create_html_view_node, lambda: isinstance(self.current_node, (ChatNode, CodeNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, WorkflowNode, QualityGateNode, GitlinkNode))) - self.command_manager.register_command("Add Artifact Drafter Node", ["artifact", "document drafter"], self.plugin_portal._create_artifact_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, WorkflowNode, ArtifactNode, QualityGateNode, GitlinkNode))) - self.command_manager.register_command("Add Workflow Architect Node", ["workflow", "orchestrate plugins", "agentic planner"], self.plugin_portal._create_workflow_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, WorkflowNode, ArtifactNode, QualityGateNode, GitlinkNode))) - self.command_manager.register_command("Add Quality Gate Node", ["quality gate", "acceptance review", "production readiness", "ship review"], self.plugin_portal._create_quality_gate_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, HtmlViewNode, WorkflowNode, ArtifactNode, QualityGateNode, GitlinkNode))) - self.command_manager.register_command("Add Execution Sandbox Node", ["sandbox", "isolated python", "requirements runner"], self.plugin_portal._create_code_sandbox_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, WorkflowNode, ArtifactNode, QualityGateNode, GitlinkNode))) - self.command_manager.register_command("Add Gitlink Node", ["gitlink", "repo context", "github repo"], self.plugin_portal._create_gitlink_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ReasoningNode, WorkflowNode, ArtifactNode, QualityGateNode, GitlinkNode))) + self.command_manager.register_command("Add Web Search Node", ["web search", "internet"], self.plugin_portal._create_web_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ArtifactNode, GitlinkNode))) + self.command_manager.register_command("Add HTML Renderer Node", ["render html", "html preview"], self.plugin_portal._create_html_view_node, lambda: isinstance(self.current_node, (ChatNode, CodeNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, GitlinkNode))) + self.command_manager.register_command("Add Artifact Drafter Node", ["artifact", "document drafter"], self.plugin_portal._create_artifact_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ArtifactNode, GitlinkNode))) + self.command_manager.register_command("Add Execution Sandbox Node", ["sandbox", "isolated python", "requirements runner"], self.plugin_portal._create_code_sandbox_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ArtifactNode, GitlinkNode))) + self.command_manager.register_command("Add Gitlink Node", ["gitlink", "repo context", "github repo"], self.plugin_portal._create_gitlink_node, lambda: isinstance(self.current_node, (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, ArtifactNode, GitlinkNode))) self.command_manager.register_command("Generate Key Takeaway", ["takeaway", "summarize node"], lambda: self.generate_takeaway(self._get_single_selected_node()), self._get_single_selected_node) self.command_manager.register_command("Generate Explainer Note", ["explain", "simplify node"], lambda: self.generate_explainer(self._get_single_selected_node()), self._get_single_selected_node) self.command_manager.register_command("Regenerate Response", ["regen", "new response"], lambda: self.regenerate_node(self._get_single_selected_node()), lambda: self._get_single_selected_node() and not self._get_single_selected_node().is_user) diff --git a/graphite_app/tests/test_code_review_scoring.py b/graphite_app/tests/test_code_review_scoring.py deleted file mode 100644 index f945e14..0000000 --- a/graphite_app/tests/test_code_review_scoring.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Tests for graphite_plugins/code_review/scoring.py (extracted from -graphite_plugin_code_review.py - see doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.2). - -The whole point of this extraction was to make the scoring/rubric engine directly -unit-testable without constructing any Qt widget or QApplication. This file proves -that by deliberately NOT creating a QApplication anywhere and NOT importing -graphite_plugin_code_review.py (the Qt-heavy widget file) at all - only the pure -scoring module. -""" - -import sys -from pathlib import Path -from unittest.mock import patch - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from graphite_plugins.code_review.scoring import ( - CODE_REVIEW_METRIC_MARKDOWN, - REVIEW_CATEGORY_LABELS, - REVIEW_CATEGORY_WEIGHTS, - SEVERITY_ORDER, - CodeReviewAnalyzer, - _clamp_score, - _clean_text, - _looks_like_python, - _severity_key, - _titleize_key, -) - - -def test_module_has_no_qt_dependency(): - import graphite_plugins.code_review.scoring as scoring_module - - source = Path(scoring_module.__file__).read_text(encoding="utf-8") - for banned in ("PySide6", "qtawesome", "QGraphics", "QWidget", "QApplication"): - assert banned not in source, f"{banned} leaked into the supposedly Qt-free scoring module" - - -def test_review_category_weights_sum_to_one_hundred(): - assert sum(REVIEW_CATEGORY_WEIGHTS.values()) == 100 - - -def test_every_weighted_category_has_a_label(): - assert set(REVIEW_CATEGORY_WEIGHTS.keys()) == set(REVIEW_CATEGORY_LABELS.keys()) - - -class TestClampScore: - def test_clamps_above_100(self): - assert _clamp_score(150) == 100 - - def test_clamps_below_0(self): - assert _clamp_score(-20) == 0 - - def test_passes_through_valid_value(self): - assert _clamp_score(73) == 73 - - def test_falls_back_to_default_on_invalid_input(self): - assert _clamp_score("not a number", default=42) == 42 - - -class TestSeverityKey: - def test_recognized_severity_passes_through(self): - assert _severity_key("high") == "high" - - def test_unrecognized_value_falls_back_to_medium(self): - assert _severity_key("catastrophic") == "medium" - - def test_is_case_insensitive(self): - assert _severity_key("CRITICAL") == "critical" - - -def test_severity_order_is_a_total_ordering_of_five_levels(): - assert set(SEVERITY_ORDER.keys()) == {"critical", "high", "medium", "low", "info"} - assert SEVERITY_ORDER["critical"] < SEVERITY_ORDER["high"] < SEVERITY_ORDER["medium"] - - -class TestTitleizeKey: - def test_converts_snake_case_to_title_case(self): - assert _titleize_key("maintainability_score") == "Maintainability Score" - - def test_converts_dashes_too(self): - assert _titleize_key("high-risk") == "High Risk" - - def test_empty_input_falls_back_to_general(self): - assert _titleize_key("") == "General" - - -class TestCleanText: - def test_collapses_excess_blank_lines(self): - assert _clean_text("a\n\n\n\n\nb") == "a\n\nb" - - def test_truncates_with_ellipsis_when_over_limit(self): - result = _clean_text("x" * 100, limit=10) - assert result.endswith("...") - assert len(result) == 10 - - -class TestLooksLikePython: - def test_py_extension_is_python(self): - assert _looks_like_python({"path": "app/main.py"}, "") is True - - def test_recognizes_python_keywords_without_a_py_path(self): - source = "import os\n\ndef main():\n class Foo:\n pass\n" - assert _looks_like_python({}, source) is True - - def test_non_python_content_is_not_python(self): - assert _looks_like_python({}, "hi") is False - - -class TestCodeReviewAnalyzerFallback: - def test_fallback_review_runs_without_any_llm_call(self): - analyzer = CodeReviewAnalyzer() - payload = { - "source_for_model": "def add(a, b):\n return a + b\n", - "source_text": "def add(a, b):\n return a + b\n", - "source_state": {"label": "math.py"}, - "source_truncated": False, - "visible_lines": 2, - } - result = analyzer._fallback_review(payload, "network unavailable") - assert "category_scores" in result - - def test_system_prompt_embeds_the_metric_markdown(self): - analyzer = CodeReviewAnalyzer() - assert CODE_REVIEW_METRIC_MARKDOWN in analyzer.SYSTEM_PROMPT - - def test_get_response_falls_back_when_the_llm_call_raises(self): - analyzer = CodeReviewAnalyzer() - payload = { - "source_for_model": "print(1)", - "source_text": "print(1)", - "source_state": {}, - "source_truncated": False, - "visible_lines": 1, - } - with patch( - "graphite_plugins.code_review.scoring.call_llm_and_parse_json", - side_effect=RuntimeError("no network in this test"), - ): - result = analyzer.get_response(payload) - assert "category_scores" in result - assert "quality_summary" in result diff --git a/graphite_app/tests/test_github_client_node_delegation.py b/graphite_app/tests/test_github_client_node_delegation.py index 6dd1c07..3ae0b55 100644 --- a/graphite_app/tests/test_github_client_node_delegation.py +++ b/graphite_app/tests/test_github_client_node_delegation.py @@ -1,9 +1,9 @@ -"""Tests that CodeReviewNode and GitlinkNode actually delegate to GitHubRestClient. +"""Tests that GitlinkNode actually delegates to GitHubRestClient. test_github_client.py covers the client's own logic in isolation; this file guards -against the two node classes silently reverting to their own hand-rolled -implementations (or the wrapper methods and the client instance drifting apart) since -that would quietly reintroduce the exact duplication Phase 3a removed. +against the node class silently reverting to its own hand-rolled implementation (or the +wrapper methods and the client instance drifting apart) since that would quietly +reintroduce the exact duplication Phase 3a removed. """ import sys @@ -17,7 +17,6 @@ _APP = QApplication.instance() or QApplication([]) from graphite_plugins.common.github_client import GitHubRestClient -from graphite_plugins.graphite_plugin_code_review import CodeReviewNode from graphite_plugins.graphite_plugin_gitlink import GitlinkNode @@ -26,36 +25,17 @@ def get_github_token(self): return "shared-token" -def test_code_review_node_uses_a_github_rest_client_instance(): - node = CodeReviewNode(parent_node=None) - assert isinstance(node._github_client, GitHubRestClient) - - def test_gitlink_node_uses_a_github_rest_client_instance(): node = GitlinkNode(parent_node=None) assert isinstance(node._github_client, GitHubRestClient) -def test_code_review_node_token_comes_from_the_shared_client(): - node = CodeReviewNode(parent_node=None, settings_manager=FakeSettingsManager()) - assert node._get_github_token() == "shared-token" - assert node._get_github_token() == node._github_client.get_token() - - def test_gitlink_node_token_comes_from_the_shared_client(): node = GitlinkNode(parent_node=None, settings_manager=FakeSettingsManager()) assert node._get_github_token() == "shared-token" assert node._get_github_token() == node._github_client.get_token() -def test_code_review_node_github_request_calls_through_to_the_client(): - node = CodeReviewNode(parent_node=None) - with patch.object(node._github_client, "request", return_value={"ok": True}) as mock_request: - result = node._github_request("https://api.github.com/x", params={"a": 1}) - mock_request.assert_called_once_with("https://api.github.com/x", {"a": 1}) - assert result == {"ok": True} - - def test_gitlink_node_github_request_calls_through_to_the_client_with_kwargs(): node = GitlinkNode(parent_node=None) with patch.object(node._github_client, "request", return_value=b"raw") as mock_request: @@ -64,9 +44,6 @@ def test_gitlink_node_github_request_calls_through_to_the_client_with_kwargs(): assert result == b"raw" -def test_both_nodes_produce_identical_headers_for_the_same_token(): - # Guards against the two files' delegation drifting into subtly different header - # shapes now that the underlying logic is shared. - cr = CodeReviewNode(parent_node=None, settings_manager=FakeSettingsManager()) +def test_gitlink_node_headers_include_the_token(): gl = GitlinkNode(parent_node=None, settings_manager=FakeSettingsManager()) - assert cr._github_headers() == gl._github_headers() + assert gl._github_headers()["Authorization"] == "Bearer shared-token" diff --git a/graphite_app/tests/test_graph_diff_node_label.py b/graphite_app/tests/test_graph_diff_node_label.py deleted file mode 100644 index 82efc25..0000000 --- a/graphite_app/tests/test_graph_diff_node_label.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests for graphite_plugin_graph_diff._node_label (Phase 2 of the plugin refactor). - -_node_label used to hardcode its own class-name -> display-name map, independently of -graphite_plugin_portal.py's registration and graphite_plugin_quality_gate.py's own copy -of the same map. That map was missing CodeReviewNode entirely (CodeReviewNode is a valid -Branch Lens comparison source per portal.py's valid_sources tuple) - comparing a branch -that ended in a Code Review Agent node would have shown the raw class name -"CodeReviewNode" in the transcript instead of "Code Review Agent". _node_label now -delegates to graphite_plugin_portal.get_display_name_for_node() (PLUGIN_REGISTRY), -which fixes that gap, plus keeps a small residual map for core node types (like -ChatNode) that aren't registered plugins. -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from PySide6.QtWidgets import QApplication - -_APP = QApplication.instance() or QApplication([]) - -from graphite_nodes.graphite_node_chat import ChatNode -from graphite_plugins.graphite_plugin_artifact import ArtifactNode -from graphite_plugins.graphite_plugin_code_review import CodeReviewNode -from graphite_plugins.graphite_plugin_gitlink import GitlinkNode -from graphite_plugins.graphite_plugin_graph_diff import GraphDiffNode, _node_label -from graphite_plugins.graphite_plugin_portal import PLUGIN_REGISTRY - - -def _instantiate(node_cls): - # GraphDiffNode alone has a different creation contract (two source nodes instead - # of a single parent_node) - see PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.5. - if node_cls is GraphDiffNode: - return node_cls(ArtifactNode(parent_node=None), ArtifactNode(parent_node=None)) - return node_cls(parent_node=None) - - -def test_code_review_node_resolves_to_its_registered_display_name(): - # This is the bug: previously fell back to the raw class name "CodeReviewNode" - # because the old hand-copied map never included it. - node = CodeReviewNode(parent_node=None) - assert _node_label(node) == "Code Review Agent" - - -def test_chat_node_uses_the_non_plugin_label(): - node = ChatNode(text="hi", is_user=True) - assert _node_label(node) == "Chat Node" - - -def test_every_registered_plugin_node_resolves_via_node_label(): - for spec in PLUGIN_REGISTRY.values(): - if spec.node_cls is None: - continue - node = _instantiate(spec.node_cls) - assert _node_label(node) == spec.display_name - - -def test_unregistered_class_falls_back_to_class_name(): - class SomeUnregisteredNode: - pass - - assert _node_label(SomeUnregisteredNode()) == "SomeUnregisteredNode" diff --git a/graphite_app/tests/test_import_chain.py b/graphite_app/tests/test_import_chain.py index c3f1a44..99ce0d7 100644 --- a/graphite_app/tests/test_import_chain.py +++ b/graphite_app/tests/test_import_chain.py @@ -30,7 +30,6 @@ import graphite_conversation_node # noqa: F401 - imports graphite_plugins.graphite_plugin_context_menu import graphite_html_view # noqa: F401 import graphite_plugins # noqa: F401 -import graphite_plugins.graphite_plugin_reasoning # noqa: F401 import graphite_pycoder # noqa: F401 - this is the module that was on the circular side import graphite_scene # noqa: F401 import graphite_web # noqa: F401 diff --git a/graphite_app/tests/test_llm_json_plugin_delegation.py b/graphite_app/tests/test_llm_json_plugin_delegation.py deleted file mode 100644 index 1cf593e..0000000 --- a/graphite_app/tests/test_llm_json_plugin_delegation.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Tests that CodeReviewAnalyzer, QualityGateAnalyzer, WorkflowArchitectAgent, and -GitlinkAgent actually delegate to the shared graphite_plugins.common.llm_json helpers -(Phase 3b), rather than each having quietly reverted to (or drifted from) its own -hand-rolled regex/network-call logic. - -Each plugin module does `from graphite_plugins.common.llm_json import -call_llm_and_parse_json, extract_json_object`, binding those names into its own module -namespace - so patching e.g. `graphite_plugin_code_review.call_llm_and_parse_json` -(not `graphite_plugins.common.llm_json.call_llm_and_parse_json`) is what proves this -particular call site is the one invoking it. - -CodeReviewAnalyzer itself now lives in graphite_plugins.code_review.scoring (extracted -out of the 2000+ line graphite_plugin_code_review.py widget file - see -doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.2), so its delegation patches target that -module instead of graphite_plugin_code_review. Likewise QualityGateAnalyzer now lives -in graphite_plugins.quality_gate.scoring (section 4.7), so its delegation patches -target that module instead of graphite_plugin_quality_gate. -""" - -import sys -from pathlib import Path -from unittest.mock import patch - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from graphite_plugins.code_review.scoring import CodeReviewAnalyzer -from graphite_plugins.graphite_plugin_gitlink import GitlinkAgent -from graphite_plugins.graphite_plugin_workflow import WorkflowArchitectAgent -from graphite_plugins.quality_gate.scoring import QualityGateAnalyzer - - -class TestExtractJsonDelegation: - def test_code_review_analyzer_extract_json_delegates(self): - analyzer = CodeReviewAnalyzer() - with patch("graphite_plugins.code_review.scoring.extract_json_object", return_value="delegated") as mock_fn: - result = analyzer._extract_json("raw text") - mock_fn.assert_called_once_with("raw text") - assert result == "delegated" - - def test_quality_gate_analyzer_clean_json_response_delegates(self): - analyzer = QualityGateAnalyzer() - with patch("graphite_plugins.quality_gate.scoring.extract_json_object", return_value="delegated") as mock_fn: - result = analyzer._clean_json_response("raw text") - mock_fn.assert_called_once_with("raw text") - assert result == "delegated" - - def test_workflow_architect_agent_clean_json_response_delegates(self): - agent = WorkflowArchitectAgent() - with patch("graphite_plugins.graphite_plugin_workflow.extract_json_object", return_value="delegated") as mock_fn: - result = agent._clean_json_response("raw text") - mock_fn.assert_called_once_with("raw text") - assert result == "delegated" - - def test_gitlink_module_extract_json_object_delegates(self): - import graphite_plugins.gitlink.agent as gitlink_agent_module - - with patch("graphite_plugins.gitlink.agent.extract_json_object", return_value="delegated") as mock_fn: - result = gitlink_agent_module._extract_json_object("raw text") - mock_fn.assert_called_once_with("raw text") - assert result == "delegated" - - -class TestCallLlmAndParseJsonDelegation: - def test_code_review_analyzer_get_response_calls_through_on_success(self): - analyzer = CodeReviewAnalyzer() - analyzer._normalize_response = lambda parsed, payload: {"parsed": parsed, "payload": payload} - payload = {"source_for_model": "print(1)", "source_state": {}, "source_truncated": False} - - with patch( - "graphite_plugins.code_review.scoring.call_llm_and_parse_json", - return_value={"quality_summary": "ok"}, - ) as mock_call: - result = analyzer.get_response(payload) - - assert mock_call.call_args.kwargs.get("task") is not None or len(mock_call.call_args.args) >= 2 - assert result["parsed"] == {"quality_summary": "ok"} - - def test_code_review_analyzer_get_response_falls_back_on_exception(self): - analyzer = CodeReviewAnalyzer() - analyzer._normalize_response = lambda parsed, payload: {"parsed": parsed} - analyzer._fallback_review = lambda payload, exc_text: {"fallback": True, "why": exc_text} - payload = {"source_for_model": "print(1)", "source_state": {}, "source_truncated": False} - - with patch( - "graphite_plugins.code_review.scoring.call_llm_and_parse_json", - side_effect=RuntimeError("boom"), - ): - result = analyzer.get_response(payload) - - assert result["parsed"] == {"fallback": True, "why": "boom"} - - def test_quality_gate_analyzer_get_response_calls_through_on_success(self): - analyzer = QualityGateAnalyzer() - analyzer._normalize_review = lambda parsed, goal, criteria, payload: {"parsed": parsed} - analyzer._build_markdown = lambda normalized, payload: "markdown" - - with patch( - "graphite_plugins.quality_gate.scoring.call_llm_and_parse_json", - return_value={"verdict": "ready"}, - ) as mock_call: - result = analyzer.get_response("goal", "criteria", {"label": "branch", "node_labels": [], "transcript": ""}) - - mock_call.assert_called_once() - assert result["parsed"] == {"verdict": "ready"} - assert result["review_markdown"] == "markdown" - - def test_quality_gate_analyzer_get_response_falls_back_on_exception(self): - analyzer = QualityGateAnalyzer() - analyzer._fallback_review = lambda goal, criteria, payload: {"fallback": True} - analyzer._build_markdown = lambda normalized, payload: "markdown" - - with patch( - "graphite_plugins.quality_gate.scoring.call_llm_and_parse_json", - side_effect=RuntimeError("boom"), - ): - result = analyzer.get_response("goal", "criteria", {"label": "branch", "node_labels": [], "transcript": ""}) - - assert result["fallback"] is True - - def test_workflow_architect_agent_get_response_falls_back_on_exception(self): - agent = WorkflowArchitectAgent() - agent._fallback_plan = lambda goal, constraints, history: {"fallback": True} - agent._build_markdown = lambda normalized: "markdown" - - with patch( - "graphite_plugins.graphite_plugin_workflow.call_llm_and_parse_json", - side_effect=RuntimeError("boom"), - ): - result = agent.get_response("goal", "constraints", []) - - assert result["fallback"] is True diff --git a/graphite_app/tests/test_per_node_worker_threads.py b/graphite_app/tests/test_per_node_worker_threads.py index 23b09c6..df2e141 100644 --- a/graphite_app/tests/test_per_node_worker_threads.py +++ b/graphite_app/tests/test_per_node_worker_threads.py @@ -35,8 +35,6 @@ import graphite_window_actions from graphite_plugins.graphite_plugin_artifact import ArtifactNode from graphite_plugins.graphite_plugin_code_sandbox import CodeSandboxNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningNode -from graphite_plugins.graphite_plugin_workflow import WorkflowNode from graphite_window_actions import WindowActionsMixin @@ -85,7 +83,7 @@ def test_main_window_has_no_shared_sandbox_thread_attribute_after_stop(self): assert not hasattr(main_window, "sandbox_thread") -class TestArtifactAndWorkflowStopMethods: +class TestArtifactStopMethods: def test_stop_artifact_node_stops_its_own_thread_and_resets_state(self): main_window = _FakeMainWindow() node = ArtifactNode(parent_node=None) @@ -100,52 +98,6 @@ def test_stop_artifact_node_stops_its_own_thread_and_resets_state(self): assert node.worker_thread is None assert node.instruction_input.isReadOnly() is False # set_running_state(False) ran - def test_stop_workflow_node_stops_its_own_thread_and_resets_state(self): - main_window = _FakeMainWindow() - node = WorkflowNode(parent_node=None) - thread = _fake_worker_thread() - node.worker_thread = thread - node.set_running_state(True) - assert node.run_button.isEnabled() is False # sanity: "running" disables the button - - main_window.stop_workflow_node(node) - - thread.stop.assert_called_once() - assert node.worker_thread is None - assert node.run_button.isEnabled() is True # set_running_state(False) ran - - -class TestReasoningStopMethod: - def test_stop_reasoning_node_stops_its_own_thread_and_resets_state(self): - main_window = _FakeMainWindow() - node = ReasoningNode(parent_node=None) - thread = _fake_worker_thread() - node.worker_thread = thread - node.set_running_state(True) - # Sanity check for the actual bug being fixed: unlike Artifact/Workflow, the - # button must stay ENABLED while running - Reasoning's run_button used to be - # disabled the whole time it showed a "stop" state, making it unclickable. - assert node.run_button.isEnabled() is True - assert node.run_button.text() == "Stop Reasoning" - - main_window.stop_reasoning_node(node) - - thread.stop.assert_called_once() - assert node.worker_thread is None - assert node.run_button.text() == "Start Reasoning" # set_running_state(False) ran - - def test_stopping_one_node_does_not_stop_a_different_concurrent_node(self): - main_window = _FakeMainWindow() - node_a = ReasoningNode(parent_node=None) - node_b = ReasoningNode(parent_node=None) - node_a.worker_thread = _fake_worker_thread() - node_b.worker_thread = _fake_worker_thread() - - main_window.stop_reasoning_node(node_a) - - assert node_a.worker_thread is None - node_b.worker_thread.stop.assert_not_called() - class TestNoDeadSharedThreadAttributesRemainInSource: def test_window_actions_source_has_no_shared_thread_assignments(self): diff --git a/graphite_app/tests/test_plugin_portal_remaining_factories.py b/graphite_app/tests/test_plugin_portal_remaining_factories.py index 91c6327..27583e9 100644 --- a/graphite_app/tests/test_plugin_portal_remaining_factories.py +++ b/graphite_app/tests/test_plugin_portal_remaining_factories.py @@ -1,13 +1,12 @@ -"""Tests for the Phase 4 migration of the remaining 10 standard-shape plugin factories -onto PluginPortal.create_node() (Py-Coder, Execution Sandbox, Workflow Architect, -Quality Gate, Code Review Agent, Gitlink, Graphlink-Web, Conversation Node, -Graphlink-Reasoning, HTML Renderer). +"""Tests for the Phase 4 migration of the standard-shape plugin factories onto +PluginPortal.create_node() (Py-Coder, Execution Sandbox, Gitlink, Graphlink-Web, +Conversation Node, HTML Renderer). Mirrors tests/test_plugin_portal_create_node.py's approach (fake scene/main_window, -real headlessly-constructed node instances) but covers all ten factories, since they -now share the same generic create_node() call shape and the same class of regression -risk: a missed signal-wire, a dropped history clone, or a warning message that silently -changed would be caught here, not just at review time. +real headlessly-constructed node instances) but covers each remaining factory, since +they now share the same generic create_node() call shape and the same class of +regression risk: a missed signal-wire, a dropped history clone, or a warning message +that silently changed would be caught here, not just at review time. """ import sys @@ -24,13 +23,9 @@ from graphite_html_view import HtmlViewNode from graphite_nodes.graphite_node_code import CodeNode from graphite_plugins.graphite_plugin_artifact import ArtifactNode -from graphite_plugins.graphite_plugin_code_review import CodeReviewConnectionItem, CodeReviewNode from graphite_plugins.graphite_plugin_code_sandbox import CodeSandboxConnectionItem, CodeSandboxNode from graphite_plugins.graphite_plugin_gitlink import GitlinkConnectionItem, GitlinkNode from graphite_plugins.graphite_plugin_portal import PluginPortal -from graphite_plugins.graphite_plugin_quality_gate import QualityGateConnectionItem, QualityGateNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningConnectionItem, ReasoningNode -from graphite_plugins.graphite_plugin_workflow import WorkflowConnectionItem, WorkflowNode from graphite_pycoder import PyCoderNode from graphite_web import WebConnectionItem, WebNode @@ -40,13 +35,9 @@ def __init__(self): for name in [ "pycoder_nodes", "pycoder_connections", "code_sandbox_nodes", "code_sandbox_connections", - "workflow_nodes", "workflow_connections", - "quality_gate_nodes", "quality_gate_connections", - "code_review_nodes", "code_review_connections", "gitlink_nodes", "gitlink_connections", "web_nodes", "web_connections", "conversation_nodes", "conversation_connections", - "reasoning_nodes", "reasoning_connections", "html_view_nodes", "html_connections", ]: setattr(self, name, []) @@ -75,15 +66,9 @@ def _make_portal(current_node): # (factory_method, node_cls, connection_cls, scene_node_attr, scene_connection_attr, # signal_name, handler_name, clones_history) -# -# Reasoning is deliberately NOT in this list (see TestReasoningExtraSignal below): it -# now needs two signals wired (reasoning_requested and stop_requested), which doesn't -# fit this tuple's one-signal-per-case shape - same reasoning as Workflow/Quality Gate's -# multi-signal cases living in their own dedicated test class instead. STANDARD_CASES = [ ("_create_pycoder_node", PyCoderNode, PyCoderConnectionItem, "pycoder_nodes", "pycoder_connections", None, None, False), ("_create_code_sandbox_node", CodeSandboxNode, CodeSandboxConnectionItem, "code_sandbox_nodes", "code_sandbox_connections", "sandbox_requested", "execute_code_sandbox_node", True), - ("_create_code_review_node", CodeReviewNode, CodeReviewConnectionItem, "code_review_nodes", "code_review_connections", "review_requested", "execute_code_review_node", True), ("_create_gitlink_node", GitlinkNode, GitlinkConnectionItem, "gitlink_nodes", "gitlink_connections", "gitlink_requested", "execute_gitlink_node", True), ("_create_web_node", WebNode, WebConnectionItem, "web_nodes", "web_connections", "run_clicked", "execute_web_node", False), ] @@ -158,17 +143,7 @@ def test_standard_factory_returns_none_with_no_selection( main_window.notification_banner.show_message.assert_called_once() -class TestCodeReviewAndGitlinkSettingsManager: - def test_code_review_node_receives_main_window_settings_manager(self): - parent = ArtifactNode(parent_node=None) - portal, main_window, scene = _make_portal(parent) - fake_settings = FakeSettingsManager() - main_window.settings_manager = fake_settings - - result = portal._create_code_review_node() - - assert result.settings_manager is fake_settings - +class TestGitlinkSettingsManager: def test_gitlink_node_receives_main_window_settings_manager(self): parent = ArtifactNode(parent_node=None) portal, main_window, scene = _make_portal(parent) @@ -192,91 +167,6 @@ def test_pycoder_resolves_through_code_node_to_its_conversational_parent(self): assert result not in getattr(code_node, "children", []) -class TestReasoningExtraSignal: - """Reasoning gained a stop_requested signal (see doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md - section 4.10 - the same real-cancellation fix Artifact/Workflow/Quality Gate - already got), so it needs both signals verified rather than fitting the - single-signal STANDARD_CASES shape above. - """ - - def test_builds_node_and_connection(self): - parent = ArtifactNode(parent_node=None) - portal, main_window, scene = _make_portal(parent) - - result = portal._create_reasoning_node() - - assert isinstance(result, ReasoningNode) - assert result in parent.children - assert result in scene.reasoning_nodes - assert isinstance(result.incoming_connection, ReasoningConnectionItem) - assert result.incoming_connection in scene.reasoning_connections - assert result.pos() == QPointF(10, 20) - - def test_wires_both_signals(self): - parent = ArtifactNode(parent_node=None) - portal, main_window, scene = _make_portal(parent) - - result = portal._create_reasoning_node() - - result.reasoning_requested.emit(result) - main_window.execute_reasoning_node.assert_called_once_with(result) - result.stop_requested.emit(result) - main_window.stop_reasoning_node.assert_called_once_with(result) - - def test_returns_none_with_no_selection(self): - portal, main_window, scene = _make_portal(current_node=None) - - result = portal._create_reasoning_node() - - assert result is None - assert scene.reasoning_nodes == [] - main_window.notification_banner.show_message.assert_called_once() - - -class TestWorkflowAndQualityGateExtraSignals: - def test_workflow_node_wires_both_signals(self): - parent = ArtifactNode(parent_node=None) - portal, main_window, scene = _make_portal(parent) - - result = portal._create_workflow_node() - - result.workflow_requested.emit(result) - main_window.execute_workflow_node.assert_called_once_with(result) - result.plugin_requested.emit(result, "Some Plugin", "prompt") - main_window.instantiate_seeded_plugin.assert_called_once_with(result, "Some Plugin", "prompt") - - def test_workflow_node_clones_parent_history(self): - parent = ArtifactNode(parent_node=None) - parent.conversation_history = [{"role": "user", "content": "hi"}] - portal, main_window, scene = _make_portal(parent) - - result = portal._create_workflow_node() - - assert result.conversation_history == [{"role": "user", "content": "hi"}] - - def test_quality_gate_node_wires_all_three_signals(self): - parent = ArtifactNode(parent_node=None) - portal, main_window, scene = _make_portal(parent) - - result = portal._create_quality_gate_node() - - result.review_requested.emit(result) - main_window.execute_quality_gate_node.assert_called_once_with(result) - result.plugin_requested.emit(result, "Some Plugin", "prompt") - main_window.instantiate_seeded_plugin.assert_called_once_with(result, "Some Plugin", "prompt") - result.note_requested.emit(result) - main_window.create_quality_gate_note.assert_called_once_with(result) - - def test_quality_gate_node_clones_parent_history(self): - parent = ArtifactNode(parent_node=None) - parent.conversation_history = [{"role": "user", "content": "hi"}] - portal, main_window, scene = _make_portal(parent) - - result = portal._create_quality_gate_node() - - assert result.conversation_history == [{"role": "user", "content": "hi"}] - - class TestConversationNodeHistoryHandling: def test_conversation_node_wires_both_signals(self): parent = ArtifactNode(parent_node=None) diff --git a/graphite_app/tests/test_plugin_registry.py b/graphite_app/tests/test_plugin_registry.py index c025266..ffd0ab2 100644 --- a/graphite_app/tests/test_plugin_registry.py +++ b/graphite_app/tests/test_plugin_registry.py @@ -29,8 +29,8 @@ ) -def test_registry_has_thirteen_entries(): - assert len(PLUGIN_REGISTRY) == 13 +def test_registry_has_eight_entries(): + assert len(PLUGIN_REGISTRY) == 8 def test_registry_names_match_the_live_portal_registration(): @@ -53,8 +53,8 @@ def test_get_plugin_spec_returns_none_for_unknown_key(): def test_get_display_name_for_node_uses_registered_name(): - spec = get_plugin_spec("code_review") - assert get_display_name_for_node(spec.node_cls) == "Code Review Agent" + spec = get_plugin_spec("conversation") + assert get_display_name_for_node(spec.node_cls) == "Conversation Node" def test_get_display_name_for_node_falls_back_to_class_name(): diff --git a/graphite_app/tests/test_quality_gate_node_label.py b/graphite_app/tests/test_quality_gate_node_label.py deleted file mode 100644 index a4fd5d3..0000000 --- a/graphite_app/tests/test_quality_gate_node_label.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Tests for graphite_plugins.quality_gate.scoring._node_label (Phase 5). - -Mirrors graphite_plugin_graph_diff._node_label's migration (see -tests/test_graph_diff_node_label.py): this map was already complete/correct (unlike -Graph Diff's, which was missing CodeReviewNode) but was still an independently -hand-maintained copy of the same class-name -> display-name knowledge PLUGIN_REGISTRY -now owns. Migrated onto graphite_plugin_portal.get_display_name_for_node() for the -same reason - one less place for this to drift out of sync in the future. - -_node_label itself now lives in graphite_plugins.quality_gate.scoring (extracted out of -graphite_plugin_quality_gate.py along with QualityGateAnalyzer - see -doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.7). - -Note: QUALITY_GATE_PLUGIN_ICONS/QUALITY_GATE_ALLOWED_PLUGINS (a separate map used for -the recommendation-card UI, not this text-label function) were deliberately NOT -migrated - their icon values differ from the registry's on purpose/by prior drift -(e.g. "Py-Coder" is "fa5s.code" here vs the registry's "fa5s.laptop-code"), so touching -them would be a visible UI change, not a behavior-preserving refactor. -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from PySide6.QtWidgets import QApplication - -_APP = QApplication.instance() or QApplication([]) - -from graphite_nodes.graphite_node_chat import ChatNode -from graphite_plugins.graphite_plugin_artifact import ArtifactNode -from graphite_plugins.graphite_plugin_code_review import CodeReviewNode -from graphite_plugins.graphite_plugin_graph_diff import GraphDiffNode -from graphite_plugins.graphite_plugin_portal import PLUGIN_REGISTRY -from graphite_plugins.quality_gate.scoring import _node_label - - -def _instantiate(node_cls): - if node_cls is GraphDiffNode: - return node_cls(ArtifactNode(parent_node=None), ArtifactNode(parent_node=None)) - return node_cls(parent_node=None) - - -def test_code_review_node_resolves_to_its_registered_display_name(): - node = CodeReviewNode(parent_node=None) - assert _node_label(node) == "Code Review Agent" - - -def test_chat_node_uses_the_non_plugin_label(): - node = ChatNode(text="hi", is_user=True) - assert _node_label(node) == "Chat Node" - - -def test_every_registered_plugin_node_resolves_via_node_label(): - for spec in PLUGIN_REGISTRY.values(): - if spec.node_cls is None: - continue - node = _instantiate(spec.node_cls) - assert _node_label(node) == spec.display_name - - -def test_unregistered_class_falls_back_to_class_name(): - class SomeUnregisteredNode: - pass - - assert _node_label(SomeUnregisteredNode()) == "SomeUnregisteredNode" diff --git a/graphite_app/tests/test_quality_gate_scoring.py b/graphite_app/tests/test_quality_gate_scoring.py deleted file mode 100644 index 655f566..0000000 --- a/graphite_app/tests/test_quality_gate_scoring.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Tests for graphite_plugins/quality_gate/scoring.py (extracted from -graphite_plugin_quality_gate.py - see doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.7). - -The whole point of this extraction was to make the scoring/rubric engine directly -unit-testable without constructing any Qt widget or QApplication. This file proves -that by deliberately NOT creating a QApplication anywhere and NOT importing -graphite_plugin_quality_gate.py (the Qt-heavy widget file) at all - only the pure -scoring module. -""" - -import sys -import types -from pathlib import Path -from unittest.mock import patch - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from graphite_plugins.quality_gate.scoring import ( - QUALITY_GATE_ALLOWED_PLUGINS, - QUALITY_GATE_PLUGIN_ICONS, - QualityGateAnalyzer, - _clean_text, - _collect_branch_nodes, - _extract_node_text, - _flatten_content, - build_quality_gate_payload, -) - - -def test_module_has_no_qt_dependency(): - import graphite_plugins.quality_gate.scoring as scoring_module - - source = Path(scoring_module.__file__).read_text(encoding="utf-8") - for banned in ("PySide6", "qtawesome", "QGraphics", "QWidget", "QApplication"): - assert banned not in source, f"{banned} leaked into the supposedly Qt-free scoring module" - - -def test_every_icon_entry_is_an_allowed_plugin(): - assert QUALITY_GATE_ALLOWED_PLUGINS == list(QUALITY_GATE_PLUGIN_ICONS.keys()) - - -class TestFlattenContent: - def test_passes_through_plain_string(self): - assert _flatten_content("hello") == "hello" - - def test_extracts_text_parts_from_content_list(self): - content = [{"type": "text", "text": "a"}, {"type": "image"}, {"type": "text", "text": "b"}] - assert _flatten_content(content) == "a\nb" - - def test_stringifies_other_types(self): - assert _flatten_content(42) == "42" - - -class TestCleanText: - def test_collapses_excess_blank_lines(self): - assert _clean_text("a\n\n\n\n\nb") == "a\n\nb" - - def test_truncates_with_ellipsis_when_over_limit(self): - result = _clean_text("x" * 100, limit=10) - assert result.endswith("...") - assert len(result) == 10 - - -class TestCollectBranchNodes: - def test_walks_parent_chain_from_root_to_leaf(self): - root = types.SimpleNamespace(parent_node=None) - middle = types.SimpleNamespace(parent_node=root) - leaf = types.SimpleNamespace(parent_node=middle) - assert _collect_branch_nodes(leaf) == [root, middle, leaf] - - def test_breaks_cycles_instead_of_looping_forever(self): - a = types.SimpleNamespace() - b = types.SimpleNamespace() - a.parent_node = b - b.parent_node = a - result = _collect_branch_nodes(a) - assert len(result) == 2 - - -class TestExtractNodeText: - def test_pulls_plain_text_attribute(self): - node = types.SimpleNamespace(text="hello world") - assert "hello world" in _extract_node_text(node) - - def test_pulls_conversation_history(self): - node = types.SimpleNamespace( - conversation_history=[{"role": "user", "content": "what is up"}] - ) - result = _extract_node_text(node) - assert "User: what is up" in result - - def test_deduplicates_identical_parts(self): - node = types.SimpleNamespace(text="same", prompt="same") - result = _extract_node_text(node) - assert result.count("same") == 1 - - -class TestBuildQualityGatePayload: - def test_single_node_without_branch_context(self): - node = types.SimpleNamespace(text="just this node", parent_node=None) - payload = build_quality_gate_payload(node, include_branch_context=False) - assert payload["depth"] == 1 - assert "just this node" in payload["transcript"] - - def test_branch_context_walks_lineage(self): - root = types.SimpleNamespace(text="root step", parent_node=None) - leaf = types.SimpleNamespace(text="leaf step", parent_node=root) - payload = build_quality_gate_payload(leaf, include_branch_context=True) - assert payload["depth"] == 2 - assert "root step" in payload["transcript"] - assert "leaf step" in payload["transcript"] - - -class TestQualityGateAnalyzerFallback: - def test_fallback_review_runs_without_any_llm_call(self): - analyzer = QualityGateAnalyzer() - payload = {"transcript": "def add(a, b):\n return a + b\n\ntest passed", "label": "branch"} - result = analyzer._fallback_review("Ship the feature", "Must pass tests", payload) - assert "verdict" in result - assert result["verdict"] in {"ready", "needs_work", "blocked"} - - def test_fallback_review_recommends_only_allowed_plugins(self): - analyzer = QualityGateAnalyzer() - payload = {"transcript": "", "label": "branch"} - result = analyzer._fallback_review("Build something", "", payload) - for item in result["recommended_plugins"]: - assert item["plugin"] in QUALITY_GATE_ALLOWED_PLUGINS - - def test_get_response_falls_back_when_the_llm_call_raises(self): - analyzer = QualityGateAnalyzer() - payload = {"transcript": "print(1)", "label": "branch", "node_labels": []} - with patch( - "graphite_plugins.quality_gate.scoring.call_llm_and_parse_json", - side_effect=RuntimeError("no network in this test"), - ): - result = analyzer.get_response("goal", "criteria", payload) - assert "verdict" in result - assert "review_markdown" in result diff --git a/graphite_app/tests/test_reasoning_agent.py b/graphite_app/tests/test_reasoning_agent.py deleted file mode 100644 index 331a801..0000000 --- a/graphite_app/tests/test_reasoning_agent.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Tests for graphite_plugins/reasoning/agent.py (see -doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 4.10 - the Graphlink-Reasoning redesign). - -This is the redesign of graphite_agents_reasoning.py's ReasoningAgent: collapses a -1 + budget*2 + 1 raw-text-call pipeline (up to 22 calls) into 1 + budget*1 + 1 -structured-JSON calls, fixing two crash/corruption bugs as a structural side effect: - -- The old plan parser (re.split(r'\\d+\\.\\s*', plan_str)) could produce an empty - plan_steps list, causing a ZeroDivisionError at `i % len(plan_steps)`. The new - _normalize_plan/_fallback_plan structurally guarantee `steps` is never empty. -- The old critique parser hunted for the literal substring "Refined Thought:" and, if - missing, used the ENTIRE raw critique response (including a "**Critique:**" preamble) - as the refined thought. The new _normalize_step_result falls back to initial_thought - specifically, never the raw response. - -Deliberately does not create a QApplication or import the widget file - this proves the -agent logic is genuinely Qt-free, matching tests/test_quality_gate_scoring.py's pattern. -""" - -import sys -from pathlib import Path -from unittest.mock import patch - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from graphite_plugins.reasoning.agent import ReasoningAgent, _truncate_thought_history - - -def test_module_has_no_qt_dependency(): - import graphite_plugins.reasoning.agent as agent_module - - source = Path(agent_module.__file__).read_text(encoding="utf-8") - for banned in ("PySide6", "qtawesome", "QGraphics", "QWidget", "QApplication"): - assert banned not in source, f"{banned} leaked into the supposedly Qt-free reasoning agent module" - - -def _chat_response(content): - return {"message": {"content": content, "role": "assistant"}} - - -class TestPlan: - def test_well_formed_plan_is_parsed(self): - agent = ReasoningAgent() - raw = '{"steps": [{"title": "Step one", "goal": "Do the first thing"}, {"title": "Step two", "goal": "Do the second thing"}]}' - with patch("graphite_plugins.reasoning.agent.api_provider.chat", return_value=_chat_response(raw)): - result = agent.plan("solve the problem", "") - assert len(result["steps"]) == 2 - assert result["steps"][0]["title"] == "Step one" - - def test_empty_response_falls_back_to_a_single_non_empty_step(self): - # This is the regression guard for the ZeroDivisionError: the old regex-based - # parser could yield an empty plan_steps list from a blank/malformed response. - agent = ReasoningAgent() - with patch("graphite_plugins.reasoning.agent.api_provider.chat", return_value=_chat_response("")): - result = agent.plan("solve the problem", "") - assert len(result["steps"]) >= 1 - - def test_non_json_response_falls_back_to_a_single_non_empty_step(self): - agent = ReasoningAgent() - with patch( - "graphite_plugins.reasoning.agent.api_provider.chat", - return_value=_chat_response("Sorry, I can't produce a plan for that."), - ): - result = agent.plan("solve the problem", "") - assert len(result["steps"]) >= 1 - - def test_plan_with_only_non_dict_steps_falls_back(self): - agent = ReasoningAgent() - raw = '{"steps": ["just a string", 42]}' - with patch("graphite_plugins.reasoning.agent.api_provider.chat", return_value=_chat_response(raw)): - result = agent.plan("solve the problem", "") - assert len(result["steps"]) >= 1 - - def test_plan_is_capped_at_max_steps(self): - agent = ReasoningAgent() - steps = [{"title": f"Step {i}", "goal": f"Goal {i}"} for i in range(30)] - import json as _json - raw = _json.dumps({"steps": steps}) - with patch("graphite_plugins.reasoning.agent.api_provider.chat", return_value=_chat_response(raw)): - result = agent.plan("solve the problem", "") - assert len(result["steps"]) <= 12 - - def test_api_failure_propagates_instead_of_falling_back(self): - # A genuine transport/model failure should abort the run, not silently degrade - # to a fallback plan - see the module docstring for why this differs from - # Quality Gate/Workflow's "fall back on any exception" convention. - agent = ReasoningAgent() - with patch("graphite_plugins.reasoning.agent.api_provider.chat", side_effect=ConnectionError("no network")): - try: - agent.plan("solve the problem", "") - assert False, "expected a RuntimeError to propagate" - except RuntimeError as exc: - assert "planning" in str(exc) - - -class TestReasonAndCritique: - def test_well_formed_result_is_parsed(self): - agent = ReasoningAgent() - raw = '{"initial_thought": "first pass", "critique": "some issues", "refined_thought": "better answer"}' - with patch("graphite_plugins.reasoning.agent.api_provider.chat", return_value=_chat_response(raw)): - result = agent.reason_and_critique("query", "", {"title": "Step", "goal": "Goal"}, []) - assert result["refined_thought"] == "better answer" - - def test_missing_refined_thought_falls_back_to_initial_thought_not_raw_response(self): - # Regression guard for the critique-pollution bug: the old code would use the - # ENTIRE raw response (including a "**Critique:**" preamble) when the - # "Refined Thought:" marker was missing. The new behavior falls back to - # initial_thought specifically. - agent = ReasoningAgent() - raw = '{"initial_thought": "first pass answer", "critique": "some issues found"}' - with patch("graphite_plugins.reasoning.agent.api_provider.chat", return_value=_chat_response(raw)): - result = agent.reason_and_critique("query", "", {"title": "Step", "goal": "Goal"}, []) - assert result["refined_thought"] == "first pass answer" - assert "**Critique:**" not in result["refined_thought"] - - def test_completely_unusable_response_falls_back_to_the_step_goal(self): - agent = ReasoningAgent() - with patch("graphite_plugins.reasoning.agent.api_provider.chat", return_value=_chat_response("not json")): - result = agent.reason_and_critique( - "query", "", {"title": "Step", "goal": "carry this goal forward"}, [] - ) - assert "carry this goal forward" in result["refined_thought"] - - def test_api_failure_propagates_instead_of_falling_back(self): - agent = ReasoningAgent() - with patch("graphite_plugins.reasoning.agent.api_provider.chat", side_effect=TimeoutError("timed out")): - try: - agent.reason_and_critique("query", "", {"title": "Step", "goal": "Goal"}, []) - assert False, "expected a RuntimeError to propagate" - except RuntimeError as exc: - assert "Step" in str(exc) - - -class TestSynthesize: - def test_returns_the_raw_message_content(self): - agent = ReasoningAgent() - with patch( - "graphite_plugins.reasoning.agent.api_provider.chat", - return_value=_chat_response("# Final Answer\n\nHere it is."), - ): - result = agent.synthesize("query", "", ["thought one"]) - assert result == "# Final Answer\n\nHere it is." - - def test_api_failure_raises(self): - agent = ReasoningAgent() - with patch("graphite_plugins.reasoning.agent.api_provider.chat", side_effect=RuntimeError("boom")): - try: - agent.synthesize("query", "", []) - assert False, "expected a RuntimeError to propagate" - except RuntimeError as exc: - assert "synthesis" in str(exc) - - -class TestTruncateThoughtHistory: - def test_empty_history_has_a_placeholder(self): - assert _truncate_thought_history([]) == "No thoughts yet." - - def test_only_the_last_n_entries_are_kept(self): - history = [f"thought {i}" for i in range(20)] - result = _truncate_thought_history(history) - assert "thought 19" in result - assert "thought 0" not in result - - def test_each_entry_is_length_capped(self): - history = ["x" * 5000] - result = _truncate_thought_history(history) - assert len(result) < 5000 - assert result.endswith("...") - - -class TestPlanBudgetModuloNeverDividesByZero: - def test_budget_larger_than_plan_step_count_loops_safely(self): - # Direct regression test for the exact crash being fixed: a budget of 10 - # against a 1-step plan must be able to loop via modulo without ever hitting - # ZeroDivisionError, because _fallback_plan guarantees at least one step. - agent = ReasoningAgent() - with patch("graphite_plugins.reasoning.agent.api_provider.chat", return_value=_chat_response("")): - plan_result = agent.plan("query", "") - steps = plan_result["steps"] - assert len(steps) > 0 - for i in range(10): - step = steps[i % len(steps)] - assert step["title"] diff --git a/graphite_app/tests/test_seed_prompt_protocol.py b/graphite_app/tests/test_seed_prompt_protocol.py index 357210c..dc6179e 100644 --- a/graphite_app/tests/test_seed_prompt_protocol.py +++ b/graphite_app/tests/test_seed_prompt_protocol.py @@ -22,15 +22,11 @@ _APP = QApplication.instance() or QApplication([]) from graphite_web import WebNode -from graphite_plugins.graphite_plugin_reasoning import ReasoningNode from graphite_pycoder import PyCoderNode from graphite_plugins.graphite_plugin_code_sandbox import CodeSandboxNode from graphite_plugins.graphite_plugin_artifact import ArtifactNode from graphite_conversation_node import ConversationNode from graphite_html_view import HtmlViewNode -from graphite_plugins.graphite_plugin_workflow import WorkflowNode -from graphite_plugins.graphite_plugin_quality_gate import QualityGateNode -from graphite_plugins.graphite_plugin_code_review import CodeReviewNode from graphite_plugins.graphite_plugin_gitlink import GitlinkNode @@ -38,15 +34,11 @@ "node_cls, get_widget_text", [ (WebNode, lambda n: n.query_input.toPlainText()), - (ReasoningNode, lambda n: n.prompt_input.toPlainText()), (PyCoderNode, lambda n: n.prompt_input.toPlainText()), (CodeSandboxNode, lambda n: n.prompt_input.toPlainText()), (ArtifactNode, lambda n: n.instruction_input.toPlainText()), (ConversationNode, lambda n: n.message_input.text()), (HtmlViewNode, lambda n: n.html_input.toPlainText()), - (WorkflowNode, lambda n: n.goal_input.toPlainText()), - (QualityGateNode, lambda n: n.goal_input.toPlainText()), - (CodeReviewNode, lambda n: n.context_input.toPlainText()), (GitlinkNode, lambda n: n.task_input.toPlainText()), ], ) @@ -62,18 +54,6 @@ def test_web_node_query_state_updates(): assert node.query == "search this" -def test_reasoning_node_prompt_state_updates(): - node = ReasoningNode(parent_node=None) - node.seed_prompt("reason about this") - assert node.prompt == "reason about this" - - -def test_code_review_node_review_context_state_updates(): - node = CodeReviewNode(parent_node=None) - node.seed_prompt("review this file") - assert node.review_context == "review this file" - - def test_gitlink_node_task_prompt_state_updates(): node = GitlinkNode(parent_node=None) node.seed_prompt("make this change") diff --git a/graphite_app/tests/test_workflow_system_prompt.py b/graphite_app/tests/test_workflow_system_prompt.py deleted file mode 100644 index 561971f..0000000 --- a/graphite_app/tests/test_workflow_system_prompt.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Tests that WorkflowArchitectAgent.SYSTEM_PROMPT's "Allowed plugins:" bullet list is -generated from WORKFLOW_ALLOWED_PLUGINS rather than a second, hand-typed copy (see -doc/PLUGIN_SYSTEM_REFACTOR_PLAN.md section 1.4/4.7). - -The two lists silently drifting apart (the prompt's hand-typed bullets missing "Code -Review Agent" after it was added to WORKFLOW_PLUGIN_ICONS/WORKFLOW_ALLOWED_PLUGINS) was -a real, already-fixed bug - see tests/test_plugin_registry.py for the registry-level -guard. This file guards the prompt-text generation itself so a future added/removed -plugin can't reintroduce the same drift. -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from PySide6.QtWidgets import QApplication - -_APP = QApplication.instance() or QApplication([]) - -from graphite_plugins.graphite_plugin_workflow import ( - WORKFLOW_ALLOWED_PLUGINS, - WorkflowArchitectAgent, -) - - -def test_every_allowed_plugin_appears_as_a_bullet_in_the_system_prompt(): - for plugin_name in WORKFLOW_ALLOWED_PLUGINS: - assert f"- {plugin_name}" in WorkflowArchitectAgent.SYSTEM_PROMPT - - -def test_bullet_count_matches_allowed_plugin_count_exactly(): - # Guards against the bullet list silently containing extra/stale entries that - # individually match the substring check above but don't correspond 1:1 with - # WORKFLOW_ALLOWED_PLUGINS. - bullet_lines = [ - line for line in WorkflowArchitectAgent.SYSTEM_PROMPT.splitlines() if line.startswith("- ") - ] - assert len(bullet_lines) == len(WORKFLOW_ALLOWED_PLUGINS) - - -def test_no_leftover_template_marker(): - assert "__ALLOWED_PLUGINS_BULLETS__" not in WorkflowArchitectAgent.SYSTEM_PROMPT - - -def test_json_shape_braces_survive_the_template_substitution(): - # The substitution is a plain str.replace(), not str.format()/an f-string, so the - # JSON example's literal braces must come through completely unescaped. - assert '"recommended_plugins": [' in WorkflowArchitectAgent.SYSTEM_PROMPT - assert WorkflowArchitectAgent.SYSTEM_PROMPT.rstrip().endswith("}")