@prism-d1/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/prism-cli.d.ts +3 -0
- package/dist/bin/prism-cli.d.ts.map +1 -0
- package/dist/bin/prism-cli.js +10 -0
- package/dist/bin/prism-cli.js.map +1 -0
- package/dist/src/commands/assessment/deploy-cdk.d.ts +22 -0
- package/dist/src/commands/assessment/deploy-cdk.d.ts.map +1 -0
- package/dist/src/commands/assessment/deploy-cdk.js +59 -0
- package/dist/src/commands/assessment/deploy-cdk.js.map +1 -0
- package/dist/src/commands/assessment/interview-agent.d.ts +71 -0
- package/dist/src/commands/assessment/interview-agent.d.ts.map +1 -0
- package/dist/src/commands/assessment/interview-agent.js +648 -0
- package/dist/src/commands/assessment/interview-agent.js.map +1 -0
- package/dist/src/commands/assessment/run.d.ts +20 -0
- package/dist/src/commands/assessment/run.d.ts.map +1 -0
- package/dist/src/commands/assessment/run.js +23 -0
- package/dist/src/commands/assessment/run.js.map +1 -0
- package/dist/src/commands/assessment/web.d.ts +15 -0
- package/dist/src/commands/assessment/web.d.ts.map +1 -0
- package/dist/src/commands/assessment/web.js +1393 -0
- package/dist/src/commands/assessment/web.js.map +1 -0
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts +15 -0
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -0
- package/dist/src/commands/bootstrapper/install-eval-harness.js +99 -0
- package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -0
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts +15 -0
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts.map +1 -0
- package/dist/src/commands/bootstrapper/install-git-hooks.js +141 -0
- package/dist/src/commands/bootstrapper/install-git-hooks.js.map +1 -0
- package/dist/src/commands/bootstrapper/setup-github-oidc.d.ts +6 -0
- package/dist/src/commands/bootstrapper/setup-github-oidc.d.ts.map +1 -0
- package/dist/src/commands/bootstrapper/setup-github-oidc.js +157 -0
- package/dist/src/commands/bootstrapper/setup-github-oidc.js.map +1 -0
- package/dist/src/commands/securityagent/setup.d.ts +14 -0
- package/dist/src/commands/securityagent/setup.d.ts.map +1 -0
- package/dist/src/commands/securityagent/setup.js +113 -0
- package/dist/src/commands/securityagent/setup.js.map +1 -0
- package/dist/src/commands/workshop/deploy-infra.d.ts +13 -0
- package/dist/src/commands/workshop/deploy-infra.d.ts.map +1 -0
- package/dist/src/commands/workshop/deploy-infra.js +73 -0
- package/dist/src/commands/workshop/deploy-infra.js.map +1 -0
- package/dist/src/commands/workshop/generate-demo-data.d.ts +16 -0
- package/dist/src/commands/workshop/generate-demo-data.d.ts.map +1 -0
- package/dist/src/commands/workshop/generate-demo-data.js +362 -0
- package/dist/src/commands/workshop/generate-demo-data.js.map +1 -0
- package/dist/src/commands/workshop/perform-pen-test.d.ts +14 -0
- package/dist/src/commands/workshop/perform-pen-test.d.ts.map +1 -0
- package/dist/src/commands/workshop/perform-pen-test.js +301 -0
- package/dist/src/commands/workshop/perform-pen-test.js.map +1 -0
- package/dist/src/commands/workshop/run-agent.d.ts +20 -0
- package/dist/src/commands/workshop/run-agent.d.ts.map +1 -0
- package/dist/src/commands/workshop/run-agent.js +89 -0
- package/dist/src/commands/workshop/run-agent.js.map +1 -0
- package/dist/src/commands/workshop/verify-setup.d.ts +14 -0
- package/dist/src/commands/workshop/verify-setup.d.ts.map +1 -0
- package/dist/src/commands/workshop/verify-setup.js +493 -0
- package/dist/src/commands/workshop/verify-setup.js.map +1 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +44 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/scanner/categories.d.ts +8 -0
- package/dist/src/scanner/categories.d.ts.map +1 -0
- package/dist/src/scanner/categories.js +322 -0
- package/dist/src/scanner/categories.js.map +1 -0
- package/dist/src/scanner/index.d.ts +16 -0
- package/dist/src/scanner/index.d.ts.map +1 -0
- package/dist/src/scanner/index.js +59 -0
- package/dist/src/scanner/index.js.map +1 -0
- package/dist/src/scanner/reporter.d.ts +3 -0
- package/dist/src/scanner/reporter.d.ts.map +1 -0
- package/dist/src/scanner/reporter.js +102 -0
- package/dist/src/scanner/reporter.js.map +1 -0
- package/dist/src/scanner/scoring.d.ts +7 -0
- package/dist/src/scanner/scoring.d.ts.map +1 -0
- package/dist/src/scanner/scoring.js +77 -0
- package/dist/src/scanner/scoring.js.map +1 -0
- package/dist/src/scanner/types.d.ts +41 -0
- package/dist/src/scanner/types.d.ts.map +1 -0
- package/dist/src/scanner/types.js +2 -0
- package/dist/src/scanner/types.js.map +1 -0
- package/dist/src/scanner/utils.d.ts +16 -0
- package/dist/src/scanner/utils.d.ts.map +1 -0
- package/dist/src/scanner/utils.js +54 -0
- package/dist/src/scanner/utils.js.map +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,1393 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { runScan } from '../../scanner/index.js';
|
|
5
|
+
import { createSession, processMessage, agentResultsToFormData, checkBedrockAccess, SECTIONS as AGENT_SECTIONS, } from './interview-agent.js';
|
|
6
|
+
// Detect ECS mode — disables repo scanning, only allows JSON import
|
|
7
|
+
const isEcsMode = !!(process.env.PRISM_ECS_MODE || process.env.ECS_CONTAINER_METADATA_URI);
|
|
8
|
+
const INTERVIEW_SECTIONS = [
|
|
9
|
+
{
|
|
10
|
+
id: 'ai_tooling_landscape', name: 'AI Tooling Landscape', maxScore: 15, time: '~10 min',
|
|
11
|
+
questions: [
|
|
12
|
+
{ id: 'q1_1', label: 'AI Tool Usage Overview', max: 5,
|
|
13
|
+
ask: 'Walk me through how your engineers use AI tools today — from IDE to deployment. What tools are in play, and how consistently are they used?',
|
|
14
|
+
listenFor: ['Specific tool names vs. vague answers', 'Standardization vs. individual choice', 'Whether tools span the full lifecycle', 'Shared configuration (team-wide settings, prompt libraries)'],
|
|
15
|
+
rubric: ['No AI tools in use', 'A few engineers use AI tools ad hoc', 'Multiple tools but no standardization', 'Standardized primary tool, some shared config', 'Standardized toolset covering multiple phases', 'Fully standardized and managed AI toolchain with usage tracking'] },
|
|
16
|
+
{ id: 'q1_2', label: 'Tool Adoption Process', max: 5,
|
|
17
|
+
ask: 'How do you decide which AI tools to adopt? Is there a process, or does it happen organically?',
|
|
18
|
+
listenFor: ['Governance vs. grassroots adoption', 'Evaluation criteria (security, cost, effectiveness)', 'Budget ownership', 'Speed of adoption'],
|
|
19
|
+
rubric: ['No process; engineers install whatever', 'Informal process, no framework', 'Some evaluation criteria but inconsistent', 'Defined process with security review, but slow', 'Streamlined evaluation with clear criteria', 'Formal but fast governance with ongoing measurement'] },
|
|
20
|
+
{ id: 'q1_3', label: 'Usage Measurement', max: 5,
|
|
21
|
+
ask: 'What percentage of your engineers use AI tools weekly? How do you know that number?',
|
|
22
|
+
listenFor: ['Actual data vs. guessing', 'Telemetry or license dashboards', 'Usage depth tracking', 'Awareness of adoption gaps'],
|
|
23
|
+
rubric: ['"I don\'t know" or clearly guessing', 'Rough guess based on anecdotes', 'Knows license count but not usage', 'Some usage data but not actively monitored', 'Actively tracks with team breakdowns', 'Real-time dashboards with usage depth and trends'] },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'dev_workflow_specs', name: 'Development Workflow & Specs', maxScore: 20, time: '~15 min',
|
|
28
|
+
questions: [
|
|
29
|
+
{ id: 'q2_1', label: 'Feature Development Flow', max: 5,
|
|
30
|
+
ask: 'When a new feature comes in, what does the journey from idea to first PR look like? Walk me through a recent example.',
|
|
31
|
+
listenFor: ['Defined process or varies by person', 'Where AI enters the workflow', 'Handoff points and bottlenecks', 'Whether process is documented'],
|
|
32
|
+
rubric: ['No consistent process', 'Loose process, AI only during coding', 'Some features get specs inconsistently', 'Defined workflow with spec phase for major features', 'Consistent spec-first workflow with AI in coding and testing', 'Fully spec-driven with AI at every phase'] },
|
|
33
|
+
{ id: 'q2_2', label: 'Spec Quality and Structure', max: 5,
|
|
34
|
+
ask: 'Do engineers write specs or design docs before coding? How structured are they?',
|
|
35
|
+
listenFor: ['Spec existence and consistency', 'Template usage and enforcement', 'Quality (vague vs. structured with ACs)', 'Whether specs live in the repo'],
|
|
36
|
+
rubric: ['No specs; code from tickets directly', 'Occasional design docs, no format', 'Specs exist but quality varies, no template', 'Template used for most features', 'Structured specs with enforcement and review', 'Rigorous spec process with ACs, constraints, AI-consumable format'] },
|
|
37
|
+
{ id: 'q2_3', label: 'AI in the Design Phase', max: 5,
|
|
38
|
+
ask: 'How does AI participate in the design phase vs. just the coding phase? Is AI involved before the first line of code is written?',
|
|
39
|
+
listenFor: ['AI usage beyond code completion', 'Prompt engineering for design tasks', 'Whether specs feed into AI for implementation', 'Left-shift maturity'],
|
|
40
|
+
rubric: ['AI only for inline code completion', 'Code completion + occasional ChatGPT queries', 'Some engineers use AI for spec drafting', 'AI regularly used for specs and planning', 'AI integrated into design phase with structured prompts', 'AI across full design lifecycle: spec drafts, gap review, implementation plans'] },
|
|
41
|
+
{ id: 'q2_4', label: 'AI Attribution and Traceability', max: 5,
|
|
42
|
+
ask: 'Look at your last 3 merged PRs. Can you tell which parts were AI-assisted?',
|
|
43
|
+
listenFor: ['Can they identify AI-assisted code at all', 'Commit trailers or metadata', 'PR descriptions mentioning AI', 'Automated tagging'],
|
|
44
|
+
rubric: ['Cannot tell which code is AI-assisted', 'Can guess from memory but no tracking', 'Some PRs mention AI inconsistently', 'Convention exists but not enforced', 'Consistent attribution via trailers, enforced', 'Automated attribution: tooling tags, searchable, auditable'] },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'cicd_quality', name: 'CI/CD & Quality', maxScore: 20, time: '~15 min',
|
|
49
|
+
questions: [
|
|
50
|
+
{ id: 'q3_1', label: 'AI Validation in CI/CD', max: 5,
|
|
51
|
+
ask: 'Walk me through your CI/CD pipeline. Where does AI-generated code get validated differently from human-written code?',
|
|
52
|
+
listenFor: ['AI-specific validation steps', 'Eval gates', 'Bedrock Evaluations or similar', 'Security scanning for AI risks'],
|
|
53
|
+
rubric: ['Standard CI only, no AI-specific steps', 'Awareness but no action', 'Extra review for AI PRs but no automation', 'Some automated checks for AI code', 'Dedicated AI validation: eval gates, security scanning', 'Comprehensive: eval gates, Bedrock Evaluations, rollback triggers, feedback loops'] },
|
|
54
|
+
{ id: 'q3_2', label: 'AI Bug Tracking', max: 5,
|
|
55
|
+
ask: 'Have you ever had an AI-generated bug reach production? What happened, and what did you learn?',
|
|
56
|
+
listenFor: ['Honesty and self-awareness', 'Whether they track AI-origin bugs separately', 'Post-mortem process', 'Process improvements from incidents'],
|
|
57
|
+
rubric: ['Don\'t track AI origin for bugs or denial', 'Aware of at least one bug, no tracking', 'Can describe incidents, response was ad hoc', 'AI bugs discussed in retros, some changes', 'AI bugs tagged in tracker, post-mortems address AI', 'Systematic tracking with defect attribution and feedback loops'] },
|
|
58
|
+
{ id: 'q3_3', label: 'AI Code Quality Measurement', max: 5,
|
|
59
|
+
ask: 'How do you measure the quality of AI-generated code vs. human-written code? Is there a difference?',
|
|
60
|
+
listenFor: ['Whether they measure quality at all', 'Defect rate comparison', 'Acceptance rate tracking', 'Quality metrics with AI dimension'],
|
|
61
|
+
rubric: ['No systematic quality measurement', 'General metrics but no AI dimension', 'Anecdotal awareness, no measurement', 'Some metrics with AI awareness', 'Explicit AI vs. human quality comparison', 'Comprehensive: defect rates, review times, acceptance rates, dashboards'] },
|
|
62
|
+
{ id: 'q3_4', label: 'Deployment Metrics and AI Impact', max: 5,
|
|
63
|
+
ask: 'What\'s your deployment frequency and lead time? How has AI affected these numbers?',
|
|
64
|
+
listenFor: ['DORA metrics awareness', 'Actual measurement', 'AI impact attribution', 'Before/after data'],
|
|
65
|
+
rubric: ['Don\'t track deployment metrics', 'Rough awareness, no formal tracking', 'Track frequency/lead time but no AI analysis', 'Track DORA, anecdotal AI impact', 'DORA with trend analysis and before/after data', 'Full DORA with AI-attributed impact analysis'] },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'metrics_visibility', name: 'Metrics & Visibility', maxScore: 15, time: '~10 min',
|
|
70
|
+
questions: [
|
|
71
|
+
{ id: 'q4_1', label: 'Executive Visibility', max: 5,
|
|
72
|
+
ask: 'If your CTO asked right now, "What is AI doing for our engineering velocity?" — what would you show them?',
|
|
73
|
+
listenFor: ['Data vs. anecdotes', 'Dashboard existence and quality', 'Real-time vs. quarterly', 'Whether leadership actually asks'],
|
|
74
|
+
rubric: ['Nothing; would rely on anecdotes', 'License costs and adoption numbers only', 'Could assemble a deck with effort', 'Periodic report or dashboard, monthly/quarterly', 'Real-time dashboard with AI contribution metrics', 'Executive-ready dashboard with ROI, trends, automated reporting'] },
|
|
75
|
+
{ id: 'q4_2', label: 'Engineering Metrics with AI Dimensions', max: 5,
|
|
76
|
+
ask: 'What engineering metrics do you currently track? Which ones include an AI dimension?',
|
|
77
|
+
listenFor: ['Baseline metrics maturity', 'AI dimensions on existing metrics', 'DORA, cycle time, throughput', 'Whether metrics drive decisions'],
|
|
78
|
+
rubric: ['Minimal or no engineering metrics', 'Basic metrics, no AI dimension', 'Standard metrics, no AI dimension', 'Good metrics + 1-2 AI-specific', 'Comprehensive with AI dimensions', 'Enhanced DORA with full AI dimensions, actively driving decisions'] },
|
|
79
|
+
{ id: 'q4_3', label: 'AI ROI Reporting', max: 5,
|
|
80
|
+
ask: 'How do you report AI ROI to leadership? What\'s the cadence and what does it include?',
|
|
81
|
+
listenFor: ['Whether ROI is reported at all', 'Quantitative vs. qualitative', 'Cadence and audience', 'Cost + benefit included'],
|
|
82
|
+
rubric: ['No AI ROI reporting', 'Occasional informal updates', 'Periodic updates with some data', 'Quarterly with quantified metrics', 'Regular with quantified ROI and exec audience', 'Structured readouts with full ROI model, trends, forecasts'] },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'governance_security', name: 'Governance & Security', maxScore: 15, time: '~10 min',
|
|
87
|
+
questions: [
|
|
88
|
+
{ id: 'q5_1', label: 'AI Guardrails', max: 5,
|
|
89
|
+
ask: 'What guardrails do you have around AI-generated code and AI agents? How do you limit what AI can do autonomously?',
|
|
90
|
+
listenFor: ['Whether guardrails exist at all', 'Specificity (vague vs. concrete rules)', 'Autonomy tiers', 'Agent-specific controls'],
|
|
91
|
+
rubric: ['No guardrails; AI has developer access', 'Informal guidance only', 'AI PRs require review but no formal policy', 'Documented guardrails with basic autonomy rules', 'Formal framework: autonomy tiers enforced by tooling', 'Comprehensive: tiers, sandboxing, restricted zones, audit trail'] },
|
|
92
|
+
{ id: 'q5_2', label: 'AI Access and Permissions', max: 5,
|
|
93
|
+
ask: 'How do you handle AI access to sensitive data, credentials, or production systems? Does AI get the same access as the developer?',
|
|
94
|
+
listenFor: ['Scoped permissions vs. inherited access', 'IAM for AI agents', 'Credential management', 'Audit trails'],
|
|
95
|
+
rubric: ['AI has same access as developer, no audit', 'Awareness but no action', 'Basic controls (no prod access)', 'Scoped permissions, credential isolation, basic audit', 'Comprehensive: scoped IAM, audit trails, trust boundaries', 'Full governance: least-privilege, audit attribution, regular reviews'] },
|
|
96
|
+
{ id: 'q5_3', label: 'AI Incident Response', max: 5,
|
|
97
|
+
ask: 'Do you have an AI-specific incident response process? If an AI agent causes a production issue, what happens?',
|
|
98
|
+
listenFor: ['AI-specific failure mode awareness', 'Runbooks or escalation paths', 'Post-mortem process for AI causes', 'Automated detection'],
|
|
99
|
+
rubric: ['No AI-specific incident response', 'Awareness but no specific process', 'Some ad hoc handling, not documented', 'AI considerations added to existing runbooks', 'Dedicated AI runbooks and escalation paths', 'Comprehensive: runbooks, automated detection, drills, feedback to guardrails'] },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'org_culture', name: 'Organization & Culture', maxScore: 15, time: '~10 min',
|
|
104
|
+
questions: [
|
|
105
|
+
{ id: 'q6_1', label: 'AI Ownership and Sponsorship', max: 5,
|
|
106
|
+
ask: 'Who owns AI engineering transformation in your org? Is there a dedicated person, team, or budget?',
|
|
107
|
+
listenFor: ['Named individual or team', 'Executive sponsorship', 'Dedicated budget', 'Strategic intent vs. organic'],
|
|
108
|
+
rubric: ['Nobody owns it; grassroots only', 'Informal champion with no authority', 'Leadership supportive but no dedicated owner', 'Named owner with partial responsibility and budget', 'Dedicated owner with mandate, budget, exec backing', 'Named owner + team, C-level sponsorship, on company roadmap with OKRs'] },
|
|
109
|
+
{ id: 'q6_2', label: 'AI Onboarding', max: 5,
|
|
110
|
+
ask: 'How do new engineers get onboarded to your AI toolchain? What does their first week look like with respect to AI tools?',
|
|
111
|
+
listenFor: ['Whether onboarding includes AI', 'Documentation and guides', 'Time-to-productivity', 'Ongoing training'],
|
|
112
|
+
rubric: ['AI not part of onboarding', 'Mentioned informally, no structured setup', 'Tools set up but no usage guidance', 'Structured: tools installed, usage guide, conventions', 'Comprehensive: codebase-specific tips, mentoring, first-week tasks', 'Full program: prompt libraries, benchmarks, ongoing training, feedback loop'] },
|
|
113
|
+
{ id: 'q6_3', label: 'Blockers and Self-Awareness', max: 5,
|
|
114
|
+
ask: 'What\'s blocking you from getting more value from AI in engineering? If you could fix one thing tomorrow, what would it be?',
|
|
115
|
+
listenFor: ['Self-awareness and honesty', 'Specificity of blockers', 'Organizational vs. technical vs. cultural', 'Willingness to change'],
|
|
116
|
+
rubric: ['"Nothing, we\'re fine" or "AI isn\'t useful"', 'Vague blockers with no specifics', 'Specific blockers but no action taken', 'Specific blockers with some efforts underway', 'Clear gaps with prioritized action plan', 'Deep self-awareness with root cause analysis and evidence of iterating'] },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
function computeBlended(scannerTotal, scannerMax, interviewTotal, org) {
|
|
121
|
+
const scannerScore = scannerMax > 0 ? (scannerTotal / scannerMax) * 100 : 0;
|
|
122
|
+
const interviewScore = interviewTotal; // already 0-100
|
|
123
|
+
let orgRaw = 0;
|
|
124
|
+
if (org.executiveSponsor)
|
|
125
|
+
orgRaw += 4;
|
|
126
|
+
if (org.budgetAllocated)
|
|
127
|
+
orgRaw += 4;
|
|
128
|
+
if (org.dedicatedOwner)
|
|
129
|
+
orgRaw += 4;
|
|
130
|
+
if (org.awsRelationship)
|
|
131
|
+
orgRaw += 4;
|
|
132
|
+
if (org.appropriateTeamSize)
|
|
133
|
+
orgRaw += 4;
|
|
134
|
+
const orgScaled = (orgRaw / 20) * 100;
|
|
135
|
+
const blended = Math.round((scannerScore * 0.4 + interviewScore * 0.4 + orgScaled * 0.2) * 100) / 100;
|
|
136
|
+
const thresholds = [
|
|
137
|
+
[81, 'L5.0'], [71, 'L4.5'], [61, 'L4.0'], [51, 'L3.5'],
|
|
138
|
+
[41, 'L3.0'], [31, 'L2.5'], [21, 'L2.0'], [11, 'L1.5'], [0, 'L1.0'],
|
|
139
|
+
];
|
|
140
|
+
let level = 'L1.0';
|
|
141
|
+
for (const [t, l] of thresholds) {
|
|
142
|
+
if (blended >= t) {
|
|
143
|
+
level = l;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
let verdict = 'NOT_QUALIFIED';
|
|
148
|
+
if (blended >= 21 && orgRaw >= 12)
|
|
149
|
+
verdict = 'READY_FOR_PILOT';
|
|
150
|
+
else if (blended >= 11 && orgRaw >= 8)
|
|
151
|
+
verdict = 'NEEDS_FOUNDATIONS';
|
|
152
|
+
return { scannerScore: Math.round(scannerScore * 100) / 100, interviewScore, orgReadinessScore: orgRaw, blendedScore: blended, level, verdict };
|
|
153
|
+
}
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// HTML templates
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
const PAGE_STYLE = `
|
|
158
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
159
|
+
body{font-family:'Amazon Ember',ember-display,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f9f9fb;color:#232F3E;line-height:1.6}
|
|
160
|
+
.page-bg-wash{position:fixed;top:0;left:0;width:100%;height:100%;z-index:-20;pointer-events:none}
|
|
161
|
+
.page-bg-wash svg{width:100%;height:100%}
|
|
162
|
+
.hero{background:#f9f9fb;position:relative;overflow:hidden;padding:56px 24px 64px}
|
|
163
|
+
.hero-inner{max-width:900px;margin:0 auto;position:relative;z-index:1;display:flex;align-items:center;gap:40px}
|
|
164
|
+
.hero-text{flex:1}
|
|
165
|
+
.hero h1{font-size:2.75rem;font-weight:700;color:#232F3E;margin-bottom:12px;line-height:1.2}
|
|
166
|
+
.hero .subtitle{color:#544F69;font-size:1.25rem;line-height:2rem}
|
|
167
|
+
.hero-cta{display:inline-block;margin-top:24px;background:linear-gradient(90deg,#DF2A5D 0.41%,#7C5AED 99.55%);color:#fff;border:none;padding:14px 28px;border-radius:100px;font-size:16px;font-weight:700;cursor:pointer;box-shadow:0 4px 32px 4px rgba(8,37,70,.18);transition:.3s;text-decoration:none}
|
|
168
|
+
.hero-cta:hover{background:linear-gradient(90deg,#7A55F4 0.41%,#DD1D53 99.11%);transform:scale(1.02)}
|
|
169
|
+
.hero-image{position:relative;flex:0 0 300px;height:220px;border-radius:20px}
|
|
170
|
+
.hero-image::before{content:'';position:absolute;inset:-30px -70px;z-index:0;border-radius:24px;background:linear-gradient(135deg,rgba(223,42,93,.12),rgba(124,90,237,.10),rgba(124,232,244,.12));filter:blur(25px)}
|
|
171
|
+
.hero-image img,.hero-image .placeholder{position:relative;z-index:1;width:100%;height:100%;object-fit:cover;border-radius:20px}
|
|
172
|
+
.hero-image .placeholder{background:linear-gradient(135deg,rgba(124,90,237,.7) 0%,rgba(124,232,244,.7) 100%);display:flex;align-items:center;justify-content:center;font-size:48px}
|
|
173
|
+
.page{max-width:900px;margin:0 auto;padding:32px 24px 40px}
|
|
174
|
+
h1{font-size:1.5rem;font-weight:700;margin-bottom:8px}
|
|
175
|
+
h2{font-size:1.25rem;font-weight:700;margin:24px 0 12px;padding-bottom:8px;border-bottom:none;color:#232F3E}
|
|
176
|
+
.card{position:relative;background:#fff;border-radius:16px;box-shadow:0 4px 40px rgba(51,0,102,.05);padding:32px;margin-bottom:24px;overflow:visible}
|
|
177
|
+
.card::before{content:'';position:absolute;inset:-20px -40px;z-index:-1;border-radius:24px;background:linear-gradient(135deg,rgba(223,42,93,.12),rgba(124,90,237,.10),rgba(124,232,244,.12));filter:blur(25px)}
|
|
178
|
+
|
|
179
|
+
.card h2{margin-top:0;padding-bottom:0}
|
|
180
|
+
label{display:block;font-weight:500;margin-bottom:4px;font-size:14px}
|
|
181
|
+
input[type=text],input[type=number],select{width:100%;padding:10px 14px;border:1px solid #DBDBE1;border-radius:8px;font-size:14px;margin-bottom:12px;transition:border-color .2s}
|
|
182
|
+
input[type=text]:focus,input[type=number]:focus,select:focus{outline:none;border-color:#2074D5;box-shadow:0 0 0 3px rgba(32,116,213,.12)}
|
|
183
|
+
input[type=number]{width:80px}
|
|
184
|
+
button{background:#2074D5;color:#fff;border:none;padding:12px 24px;border-radius:100px;font-size:14px;font-weight:700;cursor:pointer;margin-right:8px;transition:.3s;letter-spacing:.4px}
|
|
185
|
+
button:hover{background:#1766C2}
|
|
186
|
+
button.gradient{background:linear-gradient(90deg,#DF2A5D 0.41%,#7C5AED 99.55%);box-shadow:0 4px 32px 4px rgba(8,37,70,.18)}
|
|
187
|
+
button.gradient:hover{background:linear-gradient(90deg,#7A55F4 0.41%,#DD1D53 99.11%)}
|
|
188
|
+
button.secondary{background:white;color:#2074D5;border:1px solid #2074D5}
|
|
189
|
+
button.secondary:hover{background:#F6F9FF}
|
|
190
|
+
.badge{display:inline-block;padding:4px 14px;border-radius:100px;font-size:13px;font-weight:700;color:#fff}
|
|
191
|
+
.badge-green{background:#37A04D}.badge-amber{background:#FF9900}.badge-red{background:#D13212}
|
|
192
|
+
table{width:100%;border-collapse:collapse;font-size:14px;margin-top:8px}
|
|
193
|
+
th{background:#F4F3F4;color:#544F69;font-weight:700;text-align:left;padding:10px 12px;font-size:12px;text-transform:uppercase;letter-spacing:.5px}
|
|
194
|
+
td{padding:10px 12px;border-bottom:1px solid #EEEDF2}
|
|
195
|
+
.progress-bg{background:#EEEDF2;border-radius:100px;height:8px;width:100%}
|
|
196
|
+
.progress-fill{border-radius:100px;height:8px}
|
|
197
|
+
.fill-green{background:#37A04D}.fill-amber{background:#FF9900}.fill-red{background:#D13212}
|
|
198
|
+
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:20px}
|
|
199
|
+
.score-big{font-size:2.75rem;font-weight:700;background:linear-gradient(90deg,#7CE8F4,#7C5AED);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
|
200
|
+
.subtitle{color:#544F69;font-size:14px}
|
|
201
|
+
.section-q{display:flex;align-items:center;gap:12px;margin-bottom:8px}
|
|
202
|
+
.section-q label{margin:0;flex:1}
|
|
203
|
+
.section-q input{margin:0;width:70px}
|
|
204
|
+
.checkbox-row{display:flex;align-items:center;gap:8px;margin-bottom:6px}
|
|
205
|
+
.checkbox-row input{width:auto;margin:0}
|
|
206
|
+
.notes{width:100%;min-height:60px;padding:10px;border:1px solid #DBDBE1;border-radius:8px;font-size:13px;resize:vertical}
|
|
207
|
+
.hidden{display:none!important}
|
|
208
|
+
.spinner{display:inline-block;width:18px;height:18px;border:3px solid #EEEDF2;border-top-color:#7C5AED;border-radius:50%;animation:spin .6s linear infinite}
|
|
209
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
210
|
+
@media(max-width:768px){.hero-inner{flex-direction:column;text-align:center}.hero-image{display:none}.hero h1{font-size:2rem}}
|
|
211
|
+
`;
|
|
212
|
+
function scanPage() {
|
|
213
|
+
const scanSection = isEcsMode ? '' : `<div class="card" style="margin-top:20px">
|
|
214
|
+
<h2>Option A: Scan a Repository</h2>
|
|
215
|
+
<form id="scanForm" method="POST" action="/scan">
|
|
216
|
+
<label for="repoPath">Local repository path</label>
|
|
217
|
+
<input type="text" id="repoPath" name="repoPath" placeholder="/home/user/my-project" required>
|
|
218
|
+
<button type="submit">Scan Repository</button>
|
|
219
|
+
<span id="spinner" class="spinner hidden"></span>
|
|
220
|
+
</form>
|
|
221
|
+
</div>`;
|
|
222
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
223
|
+
<title>PRISM D1 Assessment</title><meta name="theme-color" content="#f9f9fb"><style>${PAGE_STYLE}</style></head><body>
|
|
224
|
+
<div class="page-bg-wash"><svg viewBox="0 0 1440 415" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none"><path d="M0 0h1440v235L0 415V0Z" fill="url(#pgbg)"/><defs><linearGradient id="pgbg" x1="0" y1="208" x2="1440" y2="208" gradientUnits="userSpaceOnUse"><stop stop-color="#7CE8F4" stop-opacity="0.08"/><stop offset="1" stop-color="#7C5AED" stop-opacity="0.04"/></linearGradient></defs></svg></div>
|
|
225
|
+
<div class="hero">
|
|
226
|
+
<div class="hero-inner">
|
|
227
|
+
<div class="hero-text">
|
|
228
|
+
<h1>PRISM D1 Velocity Assessment</h1>
|
|
229
|
+
<p class="subtitle">AI-Assisted Development Lifecycle Maturity Scanner${isEcsMode ? ' — Cloud Mode' : ''}</p>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="hero-image">
|
|
232
|
+
<div class="placeholder">🚀</div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
<div class="page">
|
|
237
|
+
${scanSection}
|
|
238
|
+
<div class="card"${isEcsMode ? ' style="margin-top:20px"' : ''}>
|
|
239
|
+
<h2>${isEcsMode ? 'Import Scan Results' : 'Option B: Import Previous Scan Results'}</h2>
|
|
240
|
+
<p class="subtitle" style="margin-bottom:12px">Upload a JSON file from a previous scan to view results and continue to the interview.</p>
|
|
241
|
+
<form id="importForm" method="POST" action="/import" enctype="multipart/form-data">
|
|
242
|
+
<input type="file" id="importFile" accept=".json" style="margin-bottom:12px" required>
|
|
243
|
+
<input type="hidden" name="scanData" id="scanDataInput">
|
|
244
|
+
<button type="submit">Import & Start Interview →</button>
|
|
245
|
+
</form>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
<script>
|
|
249
|
+
var sf = document.getElementById('scanForm');
|
|
250
|
+
if (sf) { sf.addEventListener('submit', function() { document.getElementById('spinner').classList.remove('hidden'); }); }
|
|
251
|
+
window.addEventListener('pageshow', function() { var s = document.getElementById('spinner'); if (s) s.classList.add('hidden'); });
|
|
252
|
+
document.getElementById('importForm').addEventListener('submit', function(e) {
|
|
253
|
+
var fileInput = document.getElementById('importFile');
|
|
254
|
+
console.log('[PRISM] Submit fired, file:', fileInput.files ? fileInput.files[0] : 'none');
|
|
255
|
+
if (!fileInput.files || !fileInput.files[0]) { e.preventDefault(); alert('Select a JSON file first.'); return; }
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
var reader = new FileReader();
|
|
258
|
+
reader.onload = function(ev) {
|
|
259
|
+
console.log('[PRISM] FileReader loaded, length:', ev.target.result.length);
|
|
260
|
+
try {
|
|
261
|
+
var data = JSON.parse(ev.target.result);
|
|
262
|
+
console.log('[PRISM] Parsed OK, repoName:', data.repoName, 'categories:', !!data.categories);
|
|
263
|
+
if (!data.repoName || !data.categories) { alert('Invalid scan JSON. Expected a PRISM scanner output file.'); return; }
|
|
264
|
+
var hiddenInput = document.getElementById('scanDataInput');
|
|
265
|
+
hiddenInput.value = ev.target.result;
|
|
266
|
+
console.log('[PRISM] Hidden input value length:', hiddenInput.value.length);
|
|
267
|
+
console.log('[PRISM] Submitting form, enctype:', document.getElementById('importForm').enctype);
|
|
268
|
+
document.getElementById('importForm').submit();
|
|
269
|
+
} catch(err) { alert('Could not parse JSON: ' + err.message); }
|
|
270
|
+
};
|
|
271
|
+
reader.readAsText(fileInput.files[0]);
|
|
272
|
+
});
|
|
273
|
+
</script>
|
|
274
|
+
</body></html>`;
|
|
275
|
+
}
|
|
276
|
+
function scanResultsPage(scan, imported = false) {
|
|
277
|
+
const catRows = scan.categories.map(c => {
|
|
278
|
+
const pct = c.maxPoints > 0 ? Math.round((c.earnedPoints / c.maxPoints) * 100) : 0;
|
|
279
|
+
const cls = pct >= 60 ? 'green' : pct >= 30 ? 'amber' : 'red';
|
|
280
|
+
return `<tr><td>${c.category}</td><td><strong>${c.earnedPoints}/${c.maxPoints}</strong></td>
|
|
281
|
+
<td><div class="progress-bg"><div class="progress-fill fill-${cls}" style="width:${pct}%"></div></div></td>
|
|
282
|
+
<td>${pct}%</td></tr>`;
|
|
283
|
+
}).join('');
|
|
284
|
+
const strengthsHtml = (scan.strengths || []).map(s => `<li>${s}</li>`).join('');
|
|
285
|
+
const gapsHtml = (scan.gaps || []).map(g => `<li>${g}</li>`).join('');
|
|
286
|
+
const recsHtml = (scan.recommendations || []).map(r => `<li>${r}</li>`).join('');
|
|
287
|
+
// Encode scan data for passing to interview form
|
|
288
|
+
const scanB64 = Buffer.from(JSON.stringify(scan)).toString('base64');
|
|
289
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
290
|
+
<title>Scan Results — ${scan.repoName}</title><style>${PAGE_STYLE}</style></head><body><div class="page">
|
|
291
|
+
<h1>Scan Results: ${scan.repoName}</h1>
|
|
292
|
+
<p class="subtitle">Scanned ${scan.scanDate}</p>
|
|
293
|
+
|
|
294
|
+
<div class="card">
|
|
295
|
+
<div class="grid-2">
|
|
296
|
+
<div><div class="score-big">${scan.totalScore}/${scan.maxScore}</div><div class="subtitle">Scanner Score</div></div>
|
|
297
|
+
<div><div class="score-big">${scan.prismLevel.level}</div><div class="subtitle">${scan.prismLevel.label} — ${scan.prismLevel.description}</div></div>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div class="card">
|
|
302
|
+
<h2>Category Breakdown</h2>
|
|
303
|
+
<table><thead><tr><th>Category</th><th>Score</th><th>Progress</th><th>%</th></tr></thead>
|
|
304
|
+
<tbody>${catRows}</tbody></table>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<div class="card grid-2">
|
|
308
|
+
<div><h2>Strengths</h2><ol>${strengthsHtml || '<li>None detected</li>'}</ol></div>
|
|
309
|
+
<div><h2>Gaps</h2><ol>${gapsHtml || '<li>None detected</li>'}</ol></div>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
${recsHtml ? `<div class="card"><h2>Recommendations</h2><ul>${recsHtml}</ul></div>` : ''}
|
|
313
|
+
|
|
314
|
+
<div class="card">
|
|
315
|
+
<h2>Next Steps</h2>
|
|
316
|
+
<p style="color:#475569;font-size:14px;line-height:1.7;margin-bottom:16px">The scanner covers 40% of the assessment. The remaining 60% comes from a structured interview (40%) and org readiness check (20%). The interview takes <strong>30–60 minutes</strong> and covers AI tooling, workflow, CI/CD, metrics, governance, and org culture. You have three options:</p>
|
|
317
|
+
<div style="display:grid;grid-template-columns:${imported ? '1fr 1fr' : '1fr 1fr 1fr'};gap:12px">
|
|
318
|
+
${imported ? '' : `<div style="border:1px solid #e2e8f0;border-radius:8px;padding:16px;text-align:center">
|
|
319
|
+
<div style="font-size:24px;margin-bottom:8px">📤</div>
|
|
320
|
+
<div style="font-weight:600;margin-bottom:4px">Hand off to SA</div>
|
|
321
|
+
<p class="subtitle" style="margin-bottom:12px">Export the scan results and send them to your Solutions Architect to conduct the interview.</p>
|
|
322
|
+
<form method="POST" action="/export-json"><input type="hidden" name="scanData" value="${scanB64}">
|
|
323
|
+
<button type="submit" class="secondary" style="width:100%">Export JSON</button></form>
|
|
324
|
+
</div>`}
|
|
325
|
+
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:16px;text-align:center">
|
|
326
|
+
<div style="font-size:24px;margin-bottom:8px">📋</div>
|
|
327
|
+
<div style="font-weight:600;margin-bottom:4px">Manual Interview</div>
|
|
328
|
+
<p class="subtitle" style="margin-bottom:12px">Fill out the scoring form yourself using the rubrics. Best if you're the SA running the assessment.</p>
|
|
329
|
+
<form method="POST" action="/interview"><input type="hidden" name="scanData" value="${scanB64}">
|
|
330
|
+
<button type="submit" style="width:100%">Manual Form →</button></form>
|
|
331
|
+
</div>
|
|
332
|
+
<div style="border:1px solid #7c3aed;border-radius:8px;padding:16px;text-align:center;background:#faf5ff">
|
|
333
|
+
<div style="font-size:24px;margin-bottom:8px">🤖</div>
|
|
334
|
+
<div style="font-weight:600;margin-bottom:4px">AI Agent Interview</div>
|
|
335
|
+
<p class="subtitle" style="margin-bottom:8px">An AI agent conducts the interview conversationally and scores your responses automatically.</p>
|
|
336
|
+
<p style="font-size:12px;color:#7c3aed;margin-bottom:12px">Requires Amazon Bedrock access · <a href="#" onclick="document.getElementById('bedrockModal').classList.remove('hidden');return false" style="color:#7c3aed;text-decoration:underline">Setup guide</a></p>
|
|
337
|
+
<form method="POST" action="/interview-agent"><input type="hidden" name="scanData" value="${scanB64}">
|
|
338
|
+
<button type="submit" style="width:100%;background:linear-gradient(135deg,#7c3aed,#0066ff)">Start AI Interview →</button></form>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<div id="bedrockModal" class="hidden" style="position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center" onclick="if(event.target===this)this.classList.add('hidden')">
|
|
344
|
+
<div style="background:#fff;border-radius:12px;max-width:560px;width:90%;max-height:85vh;overflow-y:auto;padding:28px;box-shadow:0 20px 60px rgba(0,0,0,.2)">
|
|
345
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
|
346
|
+
<h2 style="margin:0;border:none;padding:0">Bedrock Setup Guide</h2>
|
|
347
|
+
<button onclick="document.getElementById('bedrockModal').classList.add('hidden')" style="background:none;color:#64748b;font-size:20px;padding:4px 8px;cursor:pointer">✕</button>
|
|
348
|
+
</div>
|
|
349
|
+
<p style="color:#475569;font-size:14px;margin-bottom:16px">The AI interview agent uses <strong>Amazon Bedrock</strong> to run Claude locally. It calls the model from your machine using your AWS credentials — nothing is deployed or hosted.</p>
|
|
350
|
+
<div style="background:#f8fafc;border-radius:8px;padding:16px;margin-bottom:16px">
|
|
351
|
+
<div style="font-size:12px;color:#64748b;text-transform:uppercase;font-weight:600;margin-bottom:6px">Model Used</div>
|
|
352
|
+
<code style="font-size:14px;color:#7c3aed">us.anthropic.claude-sonnet-4-6</code>
|
|
353
|
+
<p style="font-size:12px;color:#64748b;margin-top:4px">Claude Sonnet 4.6 via cross-region inference (US)</p>
|
|
354
|
+
</div>
|
|
355
|
+
<h3 style="font-size:14px;font-weight:600;margin-bottom:8px">1. Enable model access</h3>
|
|
356
|
+
<ol style="font-size:13px;color:#475569;padding-left:20px;margin-bottom:16px;line-height:1.8">
|
|
357
|
+
<li>Open the <a href="https://console.aws.amazon.com/bedrock/home#/modelaccess" target="_blank" style="color:#0066ff">Bedrock Model Access</a> page in the AWS Console</li>
|
|
358
|
+
<li>Click <strong>Manage model access</strong></li>
|
|
359
|
+
<li>Find <strong>Anthropic → Claude Sonnet 4.6</strong> and enable it</li>
|
|
360
|
+
<li>Wait for status to show "Access granted" (usually instant)</li>
|
|
361
|
+
</ol>
|
|
362
|
+
<h3 style="font-size:14px;font-weight:600;margin-bottom:8px">2. Configure AWS credentials</h3>
|
|
363
|
+
<p style="font-size:13px;color:#475569;margin-bottom:8px">Any of these methods work:</p>
|
|
364
|
+
<div style="background:#1e293b;color:#e2e8f0;border-radius:6px;padding:12px;font-size:12px;font-family:monospace;margin-bottom:8px;line-height:1.6">
|
|
365
|
+
<span style="color:#94a3b8"># Option A: AWS CLI (recommended)</span><br>
|
|
366
|
+
aws configure<br><br>
|
|
367
|
+
<span style="color:#94a3b8"># Option B: SSO</span><br>
|
|
368
|
+
aws sso login --profile your-profile<br><br>
|
|
369
|
+
<span style="color:#94a3b8"># Option C: Environment variables</span><br>
|
|
370
|
+
export AWS_ACCESS_KEY_ID=AKIA...<br>
|
|
371
|
+
export AWS_SECRET_ACCESS_KEY=...<br>
|
|
372
|
+
export AWS_REGION=us-west-2
|
|
373
|
+
</div>
|
|
374
|
+
<h3 style="font-size:14px;font-weight:600;margin-bottom:8px">3. Start the interview</h3>
|
|
375
|
+
<p style="font-size:13px;color:#475569;margin-bottom:16px">Once model access is enabled and credentials are configured, click "Start AI Interview" on the scan results page. The agent will verify your access automatically before beginning.</p>
|
|
376
|
+
<p style="font-size:12px;color:#64748b">The agent will verify your access when the interview starts. If something is misconfigured, you'll see specific instructions on what to fix.</p>
|
|
377
|
+
<div style="text-align:center;margin-top:16px">
|
|
378
|
+
<button onclick="document.getElementById('bedrockModal').classList.add('hidden')">Got it</button>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
</div></body></html>`;
|
|
383
|
+
}
|
|
384
|
+
function interviewPage(scan) {
|
|
385
|
+
const scanB64 = Buffer.from(JSON.stringify(scan)).toString('base64');
|
|
386
|
+
// Build scanner-informed probes (from pre-interview-checklist.md)
|
|
387
|
+
const probes = [];
|
|
388
|
+
for (const cat of scan.categories) {
|
|
389
|
+
const pct = cat.maxPoints > 0 ? (cat.earnedPoints / cat.maxPoints) * 100 : 0;
|
|
390
|
+
if (pct < 30) {
|
|
391
|
+
if (cat.category.includes('Commit'))
|
|
392
|
+
probes.push(`Scanner: low AI commit attribution (${cat.earnedPoints}/${cat.maxPoints}). Probe: "How do you track which code is AI-assisted?"`);
|
|
393
|
+
else if (cat.category.includes('CI'))
|
|
394
|
+
probes.push(`Scanner: no AI eval gates in CI (${cat.earnedPoints}/${cat.maxPoints}). Probe: "Your CI doesn't have AI-specific validation. Is that intentional?"`);
|
|
395
|
+
else if (cat.category.includes('Spec'))
|
|
396
|
+
probes.push(`Scanner: no structured specs detected (${cat.earnedPoints}/${cat.maxPoints}). Probe: "Where do design decisions live?"`);
|
|
397
|
+
else if (cat.category.includes('Test'))
|
|
398
|
+
probes.push(`Scanner: low test coverage (${cat.earnedPoints}/${cat.maxPoints}). Probe: "How does AI factor into your testing strategy?"`);
|
|
399
|
+
else if (cat.category.includes('Observ'))
|
|
400
|
+
probes.push(`Scanner: no AI observability (${cat.earnedPoints}/${cat.maxPoints}). Probe: "How do you measure AI's impact on velocity?"`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const probesHtml = probes.length > 0
|
|
404
|
+
? `<div class="card" style="border-left:4px solid #f59e0b"><h2>Scanner-Informed Focus Areas</h2><p class="subtitle" style="margin-bottom:8px">Based on scanner gaps — consider these areas carefully during the interview.</p><ul>${probes.map(p => `<li>${p}</li>`).join('')}</ul></div>`
|
|
405
|
+
: '';
|
|
406
|
+
let sectionsHtml = '';
|
|
407
|
+
for (const sec of INTERVIEW_SECTIONS) {
|
|
408
|
+
let questionsHtml = '';
|
|
409
|
+
for (const q of sec.questions) {
|
|
410
|
+
const listenHtml = q.listenFor.map(l => `<li>${l}</li>`).join('');
|
|
411
|
+
const rubricHtml = q.rubric.map((r, i) => `<tr><td style="text-align:center;font-weight:600;width:30px">${i}</td><td>${r}</td></tr>`).join('');
|
|
412
|
+
questionsHtml += `
|
|
413
|
+
<div data-qid="${q.id}" data-qlabel="${q.label}" style="border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;transition:all .2s">
|
|
414
|
+
<div style="display:flex;justify-content:space-between;align-items:start;gap:12px">
|
|
415
|
+
<div style="flex:1">
|
|
416
|
+
<label for="${q.id}" style="font-size:15px;font-weight:600">${q.label}</label>
|
|
417
|
+
<p class="q-ask" style="color:#475569;font-size:13px;margin:6px 0;font-style:italic">"${q.ask}"</p>
|
|
418
|
+
</div>
|
|
419
|
+
<div style="display:flex;align-items:center;gap:6px;flex-shrink:0">
|
|
420
|
+
<select id="${q.id}" name="${q.id}" required style="width:60px"><option value="" selected>—</option><option value="0">0</option><option value="1">1</option><option value="2">2</option><option value="3">3</option><option value="4">4</option><option value="5">5</option></select>
|
|
421
|
+
<span class="subtitle">/ ${q.max}</span>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
<div class="q-detail" style="margin-top:10px;font-size:13px">
|
|
425
|
+
<div style="margin-bottom:8px"><strong style="font-size:12px;color:#64748b">WHAT TO CONSIDER:</strong><ul style="margin:4px 0 0 16px;color:#475569">${listenHtml}</ul></div>
|
|
426
|
+
<table style="font-size:12px"><thead><tr><th style="width:30px">Score</th><th>Evidence</th></tr></thead><tbody>${rubricHtml}</tbody></table>
|
|
427
|
+
</div>
|
|
428
|
+
</div>`;
|
|
429
|
+
}
|
|
430
|
+
sectionsHtml += `<div class="card">
|
|
431
|
+
<h2>${sec.name} <span class="subtitle">(max ${sec.maxScore}, ${sec.time})</span></h2>
|
|
432
|
+
${questionsHtml}
|
|
433
|
+
<label>Key findings / notes</label>
|
|
434
|
+
<textarea class="notes" name="${sec.id}_notes" placeholder="Observations for this section..."></textarea>
|
|
435
|
+
</div>`;
|
|
436
|
+
}
|
|
437
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
438
|
+
<title>Interview — ${scan.repoName}</title><style>${PAGE_STYLE}
|
|
439
|
+
[data-qid].answered{border-color:#22c55e;background:#f0fdf4;padding:10px 16px}
|
|
440
|
+
[data-qid].answered .q-detail{display:none}
|
|
441
|
+
[data-qid].answered .q-ask{display:none}
|
|
442
|
+
[data-qid].answered label{color:#16a34a}
|
|
443
|
+
</style></head><body><div class="page">
|
|
444
|
+
<h1>Assessment Interview: ${scan.repoName}</h1>
|
|
445
|
+
<p class="subtitle">Scanner score: ${scan.totalScore}/${scan.maxScore} (${scan.prismLevel.level}) · 20 questions · 60-90 minutes</p>
|
|
446
|
+
|
|
447
|
+
<div class="card" style="background:linear-gradient(135deg,#1a1a2e,#0f3460);color:#fff;margin-top:16px">
|
|
448
|
+
<p style="font-size:14px;line-height:1.7;color:#e2e8f0">This interview covers how your team builds software today, with a focus on how AI tools fit into your workflow. There are no wrong answers — the goal is to understand where you are so we can identify the most useful next steps.</p>
|
|
449
|
+
<p class="subtitle" style="color:#94a3b8;margin-top:8px">Tip: When in doubt between two scores, pick the lower one. For each question, use the scoring rubric to calibrate your answer.</p>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
${probesHtml}
|
|
453
|
+
|
|
454
|
+
<form method="POST" action="/report">
|
|
455
|
+
<input type="hidden" name="scanData" value="${scanB64}">
|
|
456
|
+
|
|
457
|
+
<div class="card">
|
|
458
|
+
<h2>Assessment Info</h2>
|
|
459
|
+
<div class="grid-2">
|
|
460
|
+
<div><label for="customerName">Customer name</label><input type="text" id="customerName" name="customerName" required></div>
|
|
461
|
+
<div><label for="saName">Completed by</label><input type="text" id="saName" name="saName" required></div>
|
|
462
|
+
<div><label for="fundingStage">Funding stage</label><select id="fundingStage" name="fundingStage"><option value="">Select...</option><option>Pre-Seed</option><option>Seed</option><option>Series A</option><option>Series B</option><option>Series C</option><option>Series D+</option><option>Growth / Late Stage</option><option>Public</option><option>Bootstrapped</option></select></div>
|
|
463
|
+
<div><label for="teamSize">Team size (engineers)</label><input type="number" id="teamSize" name="teamSize" min="1" value="10"></div>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
${sectionsHtml}
|
|
468
|
+
|
|
469
|
+
<div class="card">
|
|
470
|
+
<h2>Org Readiness</h2>
|
|
471
|
+
<div class="checkbox-row"><input type="checkbox" id="executiveSponsor" name="executiveSponsor"><label for="executiveSponsor">Executive sponsor identified</label></div>
|
|
472
|
+
<div class="checkbox-row"><input type="checkbox" id="budgetAllocated" name="budgetAllocated"><label for="budgetAllocated">Budget allocated for AI tooling</label></div>
|
|
473
|
+
<div class="checkbox-row"><input type="checkbox" id="dedicatedOwner" name="dedicatedOwner"><label for="dedicatedOwner">Dedicated AI/platform team or owner</label></div>
|
|
474
|
+
<div class="checkbox-row"><input type="checkbox" id="awsRelationship" name="awsRelationship"><label for="awsRelationship">Existing AWS commitment/relationship</label></div>
|
|
475
|
+
<div class="checkbox-row"><input type="checkbox" id="appropriateTeamSize" name="appropriateTeamSize"><label for="appropriateTeamSize">Team size appropriate (20-200 engineers)</label></div>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<div class="card"><button type="submit" id="submitBtn">Generate Report →</button>
|
|
479
|
+
<div id="validationMsg" style="color:#ef4444;font-size:14px;margin-top:8px" class="hidden"></div>
|
|
480
|
+
</div>
|
|
481
|
+
</form>
|
|
482
|
+
</div>
|
|
483
|
+
<script>
|
|
484
|
+
// Question IDs for validation
|
|
485
|
+
var qIds = [${INTERVIEW_SECTIONS.flatMap(s => s.questions.map(q => `'${q.id}'`)).join(',')}];
|
|
486
|
+
var infoIds = ['customerName','saName'];
|
|
487
|
+
|
|
488
|
+
// Collapse answered questions
|
|
489
|
+
document.querySelectorAll('select[id^="q"]').forEach(function(sel) {
|
|
490
|
+
sel.addEventListener('change', function() {
|
|
491
|
+
var card = this.closest('[data-qid]');
|
|
492
|
+
if (!card) return;
|
|
493
|
+
if (this.value !== '') {
|
|
494
|
+
card.classList.add('answered');
|
|
495
|
+
} else {
|
|
496
|
+
card.classList.remove('answered');
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Validation on submit
|
|
502
|
+
document.getElementById('submitBtn').closest('form').addEventListener('submit', function(e) {
|
|
503
|
+
var missing = [];
|
|
504
|
+
infoIds.forEach(function(id) {
|
|
505
|
+
var el = document.getElementById(id);
|
|
506
|
+
if (!el || !el.value.trim()) missing.push(el ? (el.previousElementSibling ? el.previousElementSibling.textContent : id) : id);
|
|
507
|
+
});
|
|
508
|
+
qIds.forEach(function(id) {
|
|
509
|
+
var el = document.getElementById(id);
|
|
510
|
+
if (!el) return;
|
|
511
|
+
var val = el.value;
|
|
512
|
+
if (val === '' || val === null || val === undefined) {
|
|
513
|
+
var card = el.closest('[data-qid]');
|
|
514
|
+
var name = card ? card.getAttribute('data-qlabel') : id;
|
|
515
|
+
missing.push(name);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
if (missing.length > 0) {
|
|
519
|
+
e.preventDefault();
|
|
520
|
+
var msg = document.getElementById('validationMsg');
|
|
521
|
+
msg.innerHTML = 'Please complete: ' + missing.map(function(m) { return '<strong>' + m + '</strong>'; }).join(', ');
|
|
522
|
+
msg.classList.remove('hidden');
|
|
523
|
+
msg.scrollIntoView({behavior:'smooth', block:'center'});
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
</script>
|
|
527
|
+
</body></html>`;
|
|
528
|
+
}
|
|
529
|
+
function reportPage(scan, interview, blended) {
|
|
530
|
+
const customerName = interview.customerName || scan.repoName;
|
|
531
|
+
const saName = interview.saName || 'N/A';
|
|
532
|
+
const fundingStage = interview.fundingStage || 'N/A';
|
|
533
|
+
const teamSize = interview.teamSize || 'N/A';
|
|
534
|
+
// Build interview section scores
|
|
535
|
+
let interviewRows = '';
|
|
536
|
+
let interviewTotal = 0;
|
|
537
|
+
for (const sec of INTERVIEW_SECTIONS) {
|
|
538
|
+
let secScore = 0;
|
|
539
|
+
for (const q of sec.questions) {
|
|
540
|
+
secScore += parseInt(interview[q.id] || '0', 10);
|
|
541
|
+
}
|
|
542
|
+
interviewTotal += secScore;
|
|
543
|
+
const pct = sec.maxScore > 0 ? Math.round((secScore / sec.maxScore) * 100) : 0;
|
|
544
|
+
const cls = pct >= 60 ? 'green' : pct >= 30 ? 'amber' : 'red';
|
|
545
|
+
const notes = interview[`${sec.id}_notes`] || '';
|
|
546
|
+
const notesHtml = notes ? `<br><span class="subtitle">${notes}</span>` : '';
|
|
547
|
+
interviewRows += `<tr><td><strong>${sec.name}</strong>${notesHtml}</td>
|
|
548
|
+
<td>${secScore}/${sec.maxScore}</td><td><span class="badge badge-${cls}">${pct}%</span></td></tr>`;
|
|
549
|
+
}
|
|
550
|
+
// Scanner category rows + radar chart data
|
|
551
|
+
const catData = scan.categories.map(c => {
|
|
552
|
+
const pct = c.maxPoints > 0 ? Math.round((c.earnedPoints / c.maxPoints) * 100) : 0;
|
|
553
|
+
const cls = pct >= 60 ? 'green' : pct >= 30 ? 'amber' : 'red';
|
|
554
|
+
return { name: c.category, earned: c.earnedPoints, max: c.maxPoints, pct, cls };
|
|
555
|
+
});
|
|
556
|
+
const scanRows = catData.map(c => `<tr><td>${c.name}</td><td>${c.earned}/${c.max}</td>
|
|
557
|
+
<td><div class="progress-bg"><div class="progress-fill fill-${c.cls}" style="width:${c.pct}%"></div></div></td>
|
|
558
|
+
<td>${c.pct}%</td></tr>`).join('');
|
|
559
|
+
// SVG Radar chart
|
|
560
|
+
const cx = 190, cy = 190, maxR = 150, n = catData.length;
|
|
561
|
+
const angleStep = (2 * Math.PI) / n;
|
|
562
|
+
const radarGrid = [0.25, 0.5, 0.75, 1.0].map(r => `<circle cx="${cx}" cy="${cy}" r="${Math.round(maxR * r)}" fill="none" stroke="#e2e8f0" stroke-width="1"/>`).join('');
|
|
563
|
+
const radarAxes = catData.map((_, i) => {
|
|
564
|
+
const a = -Math.PI / 2 + i * angleStep;
|
|
565
|
+
const x2 = Math.round(cx + maxR * Math.cos(a));
|
|
566
|
+
const y2 = Math.round(cy + maxR * Math.sin(a));
|
|
567
|
+
return `<line x1="${cx}" y1="${cy}" x2="${x2}" y2="${y2}" stroke="#e2e8f0" stroke-width="1"/>`;
|
|
568
|
+
}).join('');
|
|
569
|
+
const radarPoints = catData.map((c, i) => {
|
|
570
|
+
const a = -Math.PI / 2 + i * angleStep;
|
|
571
|
+
const r = (c.pct / 100) * maxR;
|
|
572
|
+
return `${Math.round(cx + r * Math.cos(a))},${Math.round(cy + r * Math.sin(a))}`;
|
|
573
|
+
}).join(' ');
|
|
574
|
+
const radarDots = catData.map((c, i) => {
|
|
575
|
+
const a = -Math.PI / 2 + i * angleStep;
|
|
576
|
+
const r = (c.pct / 100) * maxR;
|
|
577
|
+
const color = c.cls === 'green' ? '#22c55e' : c.cls === 'amber' ? '#f59e0b' : '#ef4444';
|
|
578
|
+
return `<circle cx="${Math.round(cx + r * Math.cos(a))}" cy="${Math.round(cy + r * Math.sin(a))}" r="5" fill="${color}" stroke="#fff" stroke-width="2"/>`;
|
|
579
|
+
}).join('');
|
|
580
|
+
const radarLabels = catData.map((c, i) => {
|
|
581
|
+
const a = -Math.PI / 2 + i * angleStep;
|
|
582
|
+
const lr = maxR + 20;
|
|
583
|
+
const x = Math.round(cx + lr * Math.cos(a));
|
|
584
|
+
const y = Math.round(cy + lr * Math.sin(a));
|
|
585
|
+
const anchor = Math.abs(Math.cos(a)) < 0.1 ? 'middle' : Math.cos(a) > 0 ? 'start' : 'end';
|
|
586
|
+
const shortName = c.name.length > 14 ? c.name.slice(0, 12) + '…' : c.name;
|
|
587
|
+
return `<text x="${x}" y="${y}" text-anchor="${anchor}" font-size="10" fill="#64748b">${shortName}</text>`;
|
|
588
|
+
}).join('');
|
|
589
|
+
const radarSvg = `<svg viewBox="0 0 380 380" width="380" height="380" xmlns="http://www.w3.org/2000/svg">
|
|
590
|
+
${radarGrid}${radarAxes}
|
|
591
|
+
<polygon points="${radarPoints}" fill="rgba(124,58,237,0.15)" stroke="#7c3aed" stroke-width="2"/>
|
|
592
|
+
${radarDots}${radarLabels}
|
|
593
|
+
</svg>`;
|
|
594
|
+
// Gap analysis (bottom 5 categories by percentage)
|
|
595
|
+
const allScored = [
|
|
596
|
+
...catData.map(c => ({ name: c.name, source: 'scanner', pct: c.pct, score: c.earned, max: c.max })),
|
|
597
|
+
...(() => {
|
|
598
|
+
const result = [];
|
|
599
|
+
for (const sec of INTERVIEW_SECTIONS) {
|
|
600
|
+
let s = 0;
|
|
601
|
+
for (const q of sec.questions)
|
|
602
|
+
s += parseInt(interview[q.id] || '0', 10);
|
|
603
|
+
const p = sec.maxScore > 0 ? Math.round((s / sec.maxScore) * 100) : 0;
|
|
604
|
+
result.push({ name: sec.name, source: 'interview', pct: p, score: s, max: sec.maxScore });
|
|
605
|
+
}
|
|
606
|
+
return result;
|
|
607
|
+
})(),
|
|
608
|
+
];
|
|
609
|
+
const gaps = [...allScored].sort((a, b) => a.pct - b.pct).slice(0, 5);
|
|
610
|
+
const strengths = [...allScored].sort((a, b) => b.pct - a.pct).slice(0, 3);
|
|
611
|
+
const REMEDIATION = {
|
|
612
|
+
'AI Tool Config': 'Configure Bedrock access for every developer. Establish tool version pinning policy.',
|
|
613
|
+
'Spec-Driven Dev': 'Adopt the three spec types as mandatory pre-work for AI-assisted tasks.',
|
|
614
|
+
'Commit Hygiene': 'Deploy git hooks for AI-Origin and AI-Confidence trailers.',
|
|
615
|
+
'CI/CD Integration': 'Add Bedrock Evaluation step to the primary PR pipeline.',
|
|
616
|
+
'Eval & Quality': 'Define quality rubrics for AI-generated code. Implement automated scoring.',
|
|
617
|
+
'Testing Maturity': 'Increase test coverage targets for AI-generated code.',
|
|
618
|
+
'AI Observability': 'Deploy the EventBridge metrics pipeline. Enable token tracking and cost attribution.',
|
|
619
|
+
'Governance': 'Create an AI usage governance charter. Define approval workflows.',
|
|
620
|
+
'Agent Workflows': 'Identify first candidate for a multi-step agent workflow.',
|
|
621
|
+
'Platform Reuse': 'Audit for reusable AI components. Create a shared prompt library.',
|
|
622
|
+
'Documentation': 'Add AI-assisted documentation generation to the build process.',
|
|
623
|
+
'Dependencies': 'Maintain dependency freshness and security scanning.',
|
|
624
|
+
'AI Tooling Landscape': 'Standardize AI toolset across all squads with shared configuration.',
|
|
625
|
+
'Development Workflow & Specs': 'Formalize spec-driven workflow. Ensure every AI task starts with a spec.',
|
|
626
|
+
'CI/CD & Quality': 'Integrate eval gates into all active pipelines. Define quality baselines.',
|
|
627
|
+
'Metrics & Visibility': 'Deploy the executive dashboard. Define key metrics and review cadence.',
|
|
628
|
+
'Governance & Security': 'Draft AI governance charter. Address data residency and PII concerns.',
|
|
629
|
+
'Organization & Culture': 'Run team enablement workshops. Create an internal AI champions program.',
|
|
630
|
+
};
|
|
631
|
+
const gapRows = gaps.map((g, i) => `<tr><td style="text-align:center;font-weight:700">#${i + 1}</td><td>${g.name}</td>
|
|
632
|
+
<td style="text-align:center"><span class="badge badge-${g.pct >= 60 ? 'green' : g.pct >= 30 ? 'amber' : 'red'}">${g.source}</span></td>
|
|
633
|
+
<td style="text-align:center">${g.score}/${g.max} (${g.pct}%)</td>
|
|
634
|
+
<td style="font-size:13px">${REMEDIATION[g.name] || 'Develop a targeted improvement plan with your SA.'}</td></tr>`).join('');
|
|
635
|
+
const strengthRows = strengths.map((s, i) => `<tr><td style="text-align:center;font-weight:700">#${i + 1}</td><td>${s.name}</td>
|
|
636
|
+
<td style="text-align:center"><span class="badge badge-${s.pct >= 60 ? 'green' : s.pct >= 30 ? 'amber' : 'red'}">${s.source}</span></td>
|
|
637
|
+
<td style="text-align:center">${s.score}/${s.max} (${s.pct}%)</td></tr>`).join('');
|
|
638
|
+
// Onboarding track routing
|
|
639
|
+
const level = parseFloat(blended.level.replace('L', ''));
|
|
640
|
+
const track = level >= 3.5 ? { letter: 'D', name: 'Advanced', desc: 'Custom engagement, L4+ optimization' }
|
|
641
|
+
: level >= 2.5 ? { letter: 'C', name: 'Accelerated', desc: 'Modules 03-05, targeted gaps' }
|
|
642
|
+
: level >= 2.0 ? { letter: 'B', name: 'Full Workshop', desc: 'All modules, 8-week pilot' }
|
|
643
|
+
: { letter: 'A', name: 'Foundations', desc: 'Modules 00-02, 2-week pre-work' };
|
|
644
|
+
// 90-day roadmap
|
|
645
|
+
const milestones = [
|
|
646
|
+
{ week: '1-2', milestone: 'Environment Setup & Baseline', measurable: 'All engineers have Bedrock access, baseline metrics captured' },
|
|
647
|
+
{ week: '3-4', milestone: 'Workshop Delivery', measurable: `Track ${track.letter} modules completed, eval gates configured` },
|
|
648
|
+
{ week: '5-8', milestone: 'Pilot Execution', measurable: 'AI acceptance rate ≥30%, spec-driven workflow adopted' },
|
|
649
|
+
{ week: '9-12', milestone: 'Measurement & Optimization', measurable: 'Dashboard live, PRISM level re-assessed, ROI documented' },
|
|
650
|
+
];
|
|
651
|
+
const milestoneRows = milestones.map(m => `<tr><td style="text-align:center;font-weight:600">Week ${m.week}</td><td>${m.milestone}</td><td style="font-size:13px">${m.measurable}</td></tr>`).join('');
|
|
652
|
+
// Success metrics
|
|
653
|
+
const successMetrics = [
|
|
654
|
+
{ metric: 'AI Acceptance Rate', target: '≥30%', by: 'Week 8' },
|
|
655
|
+
{ metric: 'Eval Gate Pass Rate', target: '≥80%', by: 'Week 6' },
|
|
656
|
+
{ metric: 'Lead Time Reduction', target: '≥20%', by: 'Week 12' },
|
|
657
|
+
{ metric: 'PRISM Level Increase', target: '+1.0', by: 'Week 12' },
|
|
658
|
+
];
|
|
659
|
+
const metricsRows = successMetrics.map(m => `<tr><td>${m.metric}</td><td style="font-weight:600">${m.target}</td><td>${m.by}</td></tr>`).join('');
|
|
660
|
+
const verdictCls = blended.verdict === 'READY_FOR_PILOT' ? 'green' : blended.verdict === 'NEEDS_FOUNDATIONS' ? 'amber' : 'red';
|
|
661
|
+
const verdictLabel = blended.verdict.replace(/_/g, ' ');
|
|
662
|
+
// Org readiness items
|
|
663
|
+
const orgKeys = ['executiveSponsor', 'budgetAllocated', 'dedicatedOwner', 'awsRelationship', 'appropriateTeamSize'];
|
|
664
|
+
const orgLabels = ['Executive Sponsor', 'Budget Allocated', 'Dedicated Owner', 'AWS Relationship', 'Appropriate Team Size'];
|
|
665
|
+
let orgHtml = '';
|
|
666
|
+
orgKeys.forEach((k, i) => {
|
|
667
|
+
const checked = !!interview[k];
|
|
668
|
+
const icon = checked ? '✓' : '✗';
|
|
669
|
+
const color = checked ? '#22c55e' : '#ef4444';
|
|
670
|
+
orgHtml += `<span style="margin-right:16px"><span style="color:${color};font-weight:700">${icon}</span> ${orgLabels[i]}</span>`;
|
|
671
|
+
});
|
|
672
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
673
|
+
<title>Assessment Report — ${customerName}</title><style>${PAGE_STYLE}
|
|
674
|
+
@media print{.no-print{display:none}.card{box-shadow:none;border:1px solid #e2e8f0}}
|
|
675
|
+
</style></head><body><div class="page">
|
|
676
|
+
|
|
677
|
+
<div style="background:linear-gradient(135deg,#1a1a2e,#0f3460);color:#fff;padding:36px 32px;border-radius:12px;margin-bottom:24px">
|
|
678
|
+
<h1 style="color:#fff;margin-bottom:2px">PRISM D1 Velocity Assessment</h1>
|
|
679
|
+
<div class="subtitle" style="color:#94a3b8;margin-bottom:16px">AI-Assisted Development Lifecycle Maturity Report</div>
|
|
680
|
+
<div class="grid-2" style="font-size:14px">
|
|
681
|
+
<div><span style="color:#94a3b8">Customer</span><br><strong>${customerName}</strong></div>
|
|
682
|
+
<div><span style="color:#94a3b8">Team Size</span><br><strong>${teamSize} engineers</strong></div>
|
|
683
|
+
<div><span style="color:#94a3b8">Funding Stage</span><br><strong>${fundingStage}</strong></div>
|
|
684
|
+
<div><span style="color:#94a3b8">Completed By</span><br><strong>${saName}</strong></div>
|
|
685
|
+
<div><span style="color:#94a3b8">Repository</span><br><strong style="font-family:monospace">${scan.repoName}</strong></div>
|
|
686
|
+
<div><span style="color:#94a3b8">Date</span><br><strong>${scan.scanDate}</strong></div>
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
|
|
690
|
+
<div class="card">
|
|
691
|
+
<h2>Executive Summary</h2>
|
|
692
|
+
<div style="display:flex;align-items:center;gap:24px;margin-bottom:16px">
|
|
693
|
+
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,#0066ff,#7c3aed);display:flex;align-items:center;justify-content:center;color:#fff;font-size:24px;font-weight:800;flex-shrink:0">${blended.level}</div>
|
|
694
|
+
<div>
|
|
695
|
+
<div style="font-size:20px;font-weight:700">PRISM D1 Level ${blended.level}</div>
|
|
696
|
+
<span class="badge badge-${verdictCls}">${verdictLabel}</span>
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
<div class="grid-2" style="gap:12px;margin-top:16px">
|
|
700
|
+
<div class="card" style="text-align:center;margin:0"><div class="score-big">${Math.round(blended.scannerScore)}</div><div class="subtitle">Scanner (40%)</div></div>
|
|
701
|
+
<div class="card" style="text-align:center;margin:0"><div class="score-big">${blended.interviewScore}</div><div class="subtitle">Interview (40%)</div></div>
|
|
702
|
+
</div>
|
|
703
|
+
<div class="grid-2" style="gap:12px;margin-top:12px">
|
|
704
|
+
<div class="card" style="text-align:center;margin:0"><div class="score-big">${blended.orgReadinessScore}</div><div class="subtitle">Org Readiness /20 (20%)</div></div>
|
|
705
|
+
<div class="card" style="text-align:center;margin:0"><div class="score-big">${blended.blendedScore}</div><div class="subtitle">Blended Score</div></div>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
|
|
709
|
+
<div class="card">
|
|
710
|
+
<h2>Scanner Category Breakdown</h2>
|
|
711
|
+
<div style="text-align:center;margin:20px 0">${radarSvg}</div>
|
|
712
|
+
<table><thead><tr><th>Category</th><th>Score</th><th>Progress</th><th>%</th></tr></thead>
|
|
713
|
+
<tbody>${scanRows}</tbody></table>
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
<div class="card">
|
|
717
|
+
<h2>Interview Scores</h2>
|
|
718
|
+
<table><thead><tr><th>Section</th><th>Score</th><th>Status</th></tr></thead>
|
|
719
|
+
<tbody>${interviewRows}</tbody></table>
|
|
720
|
+
</div>
|
|
721
|
+
|
|
722
|
+
<div class="card">
|
|
723
|
+
<h2>Organizational Readiness</h2>
|
|
724
|
+
<div style="margin:8px 0">${orgHtml}</div>
|
|
725
|
+
<p class="subtitle">Score: ${blended.orgReadinessScore}/20</p>
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
<div class="card">
|
|
729
|
+
<h2>Top Strengths</h2>
|
|
730
|
+
<p class="subtitle" style="margin-bottom:12px">Top 3 capabilities to build on:</p>
|
|
731
|
+
<table><thead><tr><th style="text-align:center">Rank</th><th>Area</th><th style="text-align:center">Source</th><th style="text-align:center">Score</th></tr></thead>
|
|
732
|
+
<tbody>${strengthRows}</tbody></table>
|
|
733
|
+
</div>
|
|
734
|
+
|
|
735
|
+
<div class="card">
|
|
736
|
+
<h2>Gap Analysis & Remediation</h2>
|
|
737
|
+
<p class="subtitle" style="margin-bottom:12px">Top 5 areas with the largest opportunity for improvement:</p>
|
|
738
|
+
<table><thead><tr><th style="text-align:center">Rank</th><th>Area</th><th style="text-align:center">Source</th><th style="text-align:center">Score</th><th>Recommended Action</th></tr></thead>
|
|
739
|
+
<tbody>${gapRows}</tbody></table>
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
<div class="card">
|
|
743
|
+
<h2>Onboarding Recommendation</h2>
|
|
744
|
+
<div style="margin-bottom:16px">
|
|
745
|
+
<span style="display:inline-block;padding:6px 18px;border-radius:6px;background:linear-gradient(135deg,#0066ff,#7c3aed);color:#fff;font-size:18px;font-weight:700">Track ${track.letter}: ${track.name}</span>
|
|
746
|
+
</div>
|
|
747
|
+
<p style="color:#64748b;font-size:14px">${track.desc}</p>
|
|
748
|
+
</div>
|
|
749
|
+
|
|
750
|
+
<div class="card">
|
|
751
|
+
<h2>90-Day Roadmap</h2>
|
|
752
|
+
<table><thead><tr><th style="text-align:center">When</th><th>Milestone</th><th>Measurable Outcome</th></tr></thead>
|
|
753
|
+
<tbody>${milestoneRows}</tbody></table>
|
|
754
|
+
</div>
|
|
755
|
+
|
|
756
|
+
<div class="card">
|
|
757
|
+
<h2>Success Metrics</h2>
|
|
758
|
+
<table><thead><tr><th>Metric</th><th>Target</th><th>Measure By</th></tr></thead>
|
|
759
|
+
<tbody>${metricsRows}</tbody></table>
|
|
760
|
+
</div>
|
|
761
|
+
|
|
762
|
+
${scan.recommendations.length > 0 ? `<div class="card"><h2>Recommendations</h2><ul>${scan.recommendations.map(r => `<li>${r}</li>`).join('')}</ul></div>` : ''}
|
|
763
|
+
|
|
764
|
+
<div class="card no-print" style="display:flex;gap:12px">
|
|
765
|
+
<button onclick="window.print()">Print / Save as PDF</button>
|
|
766
|
+
<a href="/"><button type="button" class="secondary">New Assessment</button></a>
|
|
767
|
+
</div>
|
|
768
|
+
|
|
769
|
+
<div style="text-align:center;padding:24px;color:#94a3b8;font-size:13px">
|
|
770
|
+
PRISM D1 Velocity Assessment Report — ${scan.scanDate}
|
|
771
|
+
</div>
|
|
772
|
+
</div></body></html>`;
|
|
773
|
+
}
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
775
|
+
// Agent interview — chat UI and session management
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
// In-memory session store (single-user local tool, so this is fine)
|
|
778
|
+
const agentSessions = new Map();
|
|
779
|
+
function agentInterviewPage(scan) {
|
|
780
|
+
const scanB64 = Buffer.from(JSON.stringify(scan)).toString('base64');
|
|
781
|
+
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
782
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
783
|
+
<title>AI Interview — ${scan.repoName}</title><style>${PAGE_STYLE}
|
|
784
|
+
.chat-container{display:flex;flex-direction:column;flex:1;min-height:0}
|
|
785
|
+
.chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px;min-height:0}
|
|
786
|
+
.msg{max-width:80%;padding:12px 16px;border-radius:12px;font-size:14px;line-height:1.6;word-wrap:break-word}
|
|
787
|
+
.msg-assistant{background:#f0f4ff;color:#1e293b;align-self:flex-start;border-bottom-left-radius:4px}
|
|
788
|
+
.msg-user{background:linear-gradient(135deg,#0066ff,#7c3aed);color:#fff;align-self:flex-end;border-bottom-right-radius:4px}
|
|
789
|
+
.msg-assistant strong{color:#0066ff}
|
|
790
|
+
.chat-input-area{display:flex;gap:8px;padding:16px;border-top:1px solid #e2e8f0;background:#fff;flex-shrink:0}
|
|
791
|
+
.chat-input-area textarea{flex:1;padding:10px 14px;border:1px solid #cbd5e1;border-radius:8px;font-size:14px;resize:none;min-height:44px;max-height:120px;font-family:inherit;line-height:1.5}
|
|
792
|
+
.chat-input-area textarea:focus{outline:none;border-color:#0066ff;box-shadow:0 0 0 3px rgba(0,102,255,.1)}
|
|
793
|
+
.chat-input-area button{align-self:flex-end}
|
|
794
|
+
.chat-input-area button:disabled{opacity:.5;cursor:not-allowed}
|
|
795
|
+
.typing-indicator{display:flex;gap:4px;padding:8px 16px;align-self:flex-start}
|
|
796
|
+
.typing-indicator span{width:8px;height:8px;background:#94a3b8;border-radius:50%;animation:bounce .6s infinite alternate}
|
|
797
|
+
.typing-indicator span:nth-child(2){animation-delay:.2s}
|
|
798
|
+
.typing-indicator span:nth-child(3){animation-delay:.4s}
|
|
799
|
+
@keyframes bounce{to{transform:translateY(-6px);opacity:.4}}
|
|
800
|
+
.progress-bar-wrap{display:flex;align-items:center;gap:12px;padding:8px 16px;background:#f8fafc;border-bottom:1px solid #e2e8f0;font-size:13px;color:#64748b;flex-shrink:0}
|
|
801
|
+
.progress-bar-wrap .progress-bg{flex:1;max-width:300px}
|
|
802
|
+
.agent-complete-bar{padding:16px;background:#f0fdf4;border-top:1px solid #bbf7d0;text-align:center}
|
|
803
|
+
.status-panel{display:flex;gap:6px;padding:10px 16px;background:#f8fafc;border-bottom:1px solid #e2e8f0;flex-wrap:wrap;align-items:center;flex-shrink:0}
|
|
804
|
+
.status-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:500;border:1px solid #e2e8f0;background:#fff;color:#64748b;transition:all .3s}
|
|
805
|
+
.status-chip.active{border-color:#0066ff;background:#eff6ff;color:#0066ff;font-weight:600}
|
|
806
|
+
.status-chip.done{border-color:#22c55e;background:#f0fdf4;color:#16a34a}
|
|
807
|
+
.status-chip .chip-icon{font-size:11px}
|
|
808
|
+
.info-bar{display:flex;gap:16px;padding:8px 16px;background:#fefce8;border-bottom:1px solid #fef08a;font-size:12px;color:#854d0e;flex-wrap:wrap;flex-shrink:0}
|
|
809
|
+
.info-bar.complete{background:#f0fdf4;border-color:#bbf7d0;color:#166534}
|
|
810
|
+
.info-item{display:inline-flex;align-items:center;gap:4px}
|
|
811
|
+
.info-item .check{color:#22c55e}
|
|
812
|
+
.info-item .missing{color:#f59e0b}
|
|
813
|
+
</style></head><body><div class="page" style="padding-bottom:0;height:100vh;display:flex;flex-direction:column;overflow:hidden">
|
|
814
|
+
<h1 style="flex-shrink:0">AI-Assisted Interview: ${scan.repoName}</h1>
|
|
815
|
+
<p class="subtitle" style="flex-shrink:0">Scanner score: ${scan.totalScore}/${scan.maxScore} (${scan.prismLevel.level}) · The AI agent will conduct the interview conversationally</p>
|
|
816
|
+
|
|
817
|
+
<div class="card" style="margin-top:16px;padding:0;overflow:hidden;flex:1;display:flex;flex-direction:column;min-height:0">
|
|
818
|
+
<div class="status-panel" id="statusPanel">
|
|
819
|
+
<span class="status-chip active" id="chip-intro"><span class="chip-icon">●</span> Info</span>
|
|
820
|
+
<span class="status-chip" id="chip-s1"><span class="chip-icon">○</span> AI Tooling</span>
|
|
821
|
+
<span class="status-chip" id="chip-s2"><span class="chip-icon">○</span> Workflow</span>
|
|
822
|
+
<span class="status-chip" id="chip-s3"><span class="chip-icon">○</span> CI/CD</span>
|
|
823
|
+
<span class="status-chip" id="chip-s4"><span class="chip-icon">○</span> Metrics</span>
|
|
824
|
+
<span class="status-chip" id="chip-s5"><span class="chip-icon">○</span> Governance</span>
|
|
825
|
+
<span class="status-chip" id="chip-s6"><span class="chip-icon">○</span> Org</span>
|
|
826
|
+
<span class="status-chip" id="chip-readiness"><span class="chip-icon">○</span> Readiness</span>
|
|
827
|
+
</div>
|
|
828
|
+
<div class="info-bar" id="infoBar">
|
|
829
|
+
<span class="info-item" id="info-name"><span class="missing">○</span> Company</span>
|
|
830
|
+
<span class="info-item" id="info-team"><span class="missing">○</span> Team size</span>
|
|
831
|
+
<span class="info-item" id="info-funding"><span class="missing">○</span> Funding</span>
|
|
832
|
+
<span style="margin-left:auto;font-size:11px;color:#a16207" id="infoHint">Collecting background info...</span>
|
|
833
|
+
</div>
|
|
834
|
+
<div class="progress-bar-wrap">
|
|
835
|
+
<span id="progressLabel">Starting interview...</span>
|
|
836
|
+
<div class="progress-bg"><div id="progressFill" class="progress-fill fill-green" style="width:0%"></div></div>
|
|
837
|
+
<span id="progressPct">0/20</span>
|
|
838
|
+
</div>
|
|
839
|
+
|
|
840
|
+
<div class="chat-container">
|
|
841
|
+
<div class="chat-messages" id="chatMessages"></div>
|
|
842
|
+
<div id="typingIndicator" class="typing-indicator hidden"><span></span><span></span><span></span></div>
|
|
843
|
+
<div class="chat-input-area" id="inputArea">
|
|
844
|
+
<textarea id="userInput" placeholder="Type your response..." rows="2"></textarea>
|
|
845
|
+
<button id="sendBtn" onclick="sendMessage()">Send</button>
|
|
846
|
+
</div>
|
|
847
|
+
<div id="completeBar" class="agent-complete-bar hidden">
|
|
848
|
+
<form method="POST" action="/agent-report">
|
|
849
|
+
<input type="hidden" name="sessionId" id="sessionIdInput" value="${sessionId}">
|
|
850
|
+
<button type="submit">View Full Report →</button>
|
|
851
|
+
</form>
|
|
852
|
+
</div>
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
<script>
|
|
858
|
+
var sessionId = '${sessionId}';
|
|
859
|
+
var scanB64 = '${scanB64}';
|
|
860
|
+
var isDone = false;
|
|
861
|
+
|
|
862
|
+
// Initialize session
|
|
863
|
+
fetch('/api/agent/init', {
|
|
864
|
+
method: 'POST',
|
|
865
|
+
headers: {'Content-Type':'application/json'},
|
|
866
|
+
body: JSON.stringify({ sessionId: sessionId, scanData: scanB64 })
|
|
867
|
+
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
868
|
+
if (data.setupError) {
|
|
869
|
+
// Show setup instructions instead of chat
|
|
870
|
+
var container = document.getElementById('chatMessages');
|
|
871
|
+
container.innerHTML = '<div style="padding:24px;max-width:600px;margin:0 auto">'
|
|
872
|
+
+ '<div style="text-align:center;font-size:48px;margin-bottom:16px">⚠️</div>'
|
|
873
|
+
+ '<h2 style="text-align:center;margin-bottom:16px;color:#ef4444">Bedrock Access Required</h2>'
|
|
874
|
+
+ '<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:20px;font-size:14px;line-height:1.7">'
|
|
875
|
+
+ data.instructions
|
|
876
|
+
+ '</div>'
|
|
877
|
+
+ '<div style="text-align:center;margin-top:20px">'
|
|
878
|
+
+ '<button onclick="location.reload()" style="margin-right:8px">🔄 Retry</button>'
|
|
879
|
+
+ '<a href="/"><button type="button" class="secondary">← Back to Scanner</button></a>'
|
|
880
|
+
+ '</div></div>';
|
|
881
|
+
document.getElementById('inputArea').classList.add('hidden');
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (data.reply) appendMessage('assistant', data.reply);
|
|
885
|
+
updateProgress(data.progress || 0, data.progressLabel || '', data.status || null);
|
|
886
|
+
}).catch(function(err) {
|
|
887
|
+
appendMessage('assistant', 'Error initializing interview agent: ' + err.message + '. Make sure you have AWS credentials configured with Bedrock access.');
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
function appendMessage(role, text) {
|
|
891
|
+
var container = document.getElementById('chatMessages');
|
|
892
|
+
var div = document.createElement('div');
|
|
893
|
+
div.className = 'msg msg-' + role;
|
|
894
|
+
// Simple markdown-ish rendering
|
|
895
|
+
div.innerHTML = text
|
|
896
|
+
.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
|
|
897
|
+
.replace(/\\n/g, '<br>');
|
|
898
|
+
container.appendChild(div);
|
|
899
|
+
container.scrollTop = container.scrollHeight;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function updateProgress(pct, label, status) {
|
|
903
|
+
document.getElementById('progressFill').style.width = pct + '%';
|
|
904
|
+
document.getElementById('progressPct').textContent = (status ? status.questionsAnswered || 0 : 0) + '/20';
|
|
905
|
+
if (label) document.getElementById('progressLabel').textContent = label;
|
|
906
|
+
|
|
907
|
+
if (!status) return;
|
|
908
|
+
|
|
909
|
+
// Update info bar
|
|
910
|
+
var infoBar = document.getElementById('infoBar');
|
|
911
|
+
var fields = [
|
|
912
|
+
{ id: 'info-name', key: 'customerName', label: 'Company' },
|
|
913
|
+
{ id: 'info-team', key: 'teamSize', label: 'Team size' },
|
|
914
|
+
{ id: 'info-funding', key: 'fundingStage', label: 'Funding' },
|
|
915
|
+
];
|
|
916
|
+
var allCollected = true;
|
|
917
|
+
fields.forEach(function(f) {
|
|
918
|
+
var el = document.getElementById(f.id);
|
|
919
|
+
var val = status[f.key];
|
|
920
|
+
if (val && val !== 'Unknown' && val !== '' && val !== 0 && val !== '0') {
|
|
921
|
+
el.innerHTML = '<span class="check">✓</span> ' + f.label + ': <strong>' + val + '</strong>';
|
|
922
|
+
} else {
|
|
923
|
+
el.innerHTML = '<span class="missing">○</span> ' + f.label;
|
|
924
|
+
allCollected = false;
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
var hint = document.getElementById('infoHint');
|
|
928
|
+
if (allCollected) {
|
|
929
|
+
infoBar.className = 'info-bar complete';
|
|
930
|
+
hint.textContent = '✓ All info collected';
|
|
931
|
+
hint.style.color = '#166534';
|
|
932
|
+
} else if (status.phase !== 'intro') {
|
|
933
|
+
hint.textContent = '';
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Update phase chips
|
|
937
|
+
var chipMap = {
|
|
938
|
+
'intro': 'chip-intro',
|
|
939
|
+
's1': 'chip-s1', 's2': 'chip-s2', 's3': 'chip-s3',
|
|
940
|
+
's4': 'chip-s4', 's5': 'chip-s5', 's6': 'chip-s6',
|
|
941
|
+
'readiness': 'chip-readiness',
|
|
942
|
+
};
|
|
943
|
+
// Reset all chips
|
|
944
|
+
Object.values(chipMap).forEach(function(id) {
|
|
945
|
+
var el = document.getElementById(id);
|
|
946
|
+
if (el) { el.className = 'status-chip'; el.querySelector('.chip-icon').textContent = '○'; }
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
// Mark completed phases
|
|
950
|
+
if (status.completedSections) {
|
|
951
|
+
status.completedSections.forEach(function(s) {
|
|
952
|
+
var el = document.getElementById(chipMap[s]);
|
|
953
|
+
if (el) { el.className = 'status-chip done'; el.querySelector('.chip-icon').textContent = '✓'; }
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Mark active phase
|
|
958
|
+
var activeChip = status.activeChip;
|
|
959
|
+
if (activeChip && chipMap[activeChip]) {
|
|
960
|
+
var el = document.getElementById(chipMap[activeChip]);
|
|
961
|
+
if (el && !el.classList.contains('done')) {
|
|
962
|
+
el.className = 'status-chip active';
|
|
963
|
+
el.querySelector('.chip-icon').textContent = '●';
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function sendMessage() {
|
|
969
|
+
if (isDone) return;
|
|
970
|
+
var input = document.getElementById('userInput');
|
|
971
|
+
var text = input.value.trim();
|
|
972
|
+
if (!text) return;
|
|
973
|
+
|
|
974
|
+
appendMessage('user', text);
|
|
975
|
+
input.value = '';
|
|
976
|
+
input.disabled = true;
|
|
977
|
+
document.getElementById('sendBtn').disabled = true;
|
|
978
|
+
document.getElementById('typingIndicator').classList.remove('hidden');
|
|
979
|
+
|
|
980
|
+
fetch('/api/agent/chat', {
|
|
981
|
+
method: 'POST',
|
|
982
|
+
headers: {'Content-Type':'application/json'},
|
|
983
|
+
body: JSON.stringify({ sessionId: sessionId, message: text })
|
|
984
|
+
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
985
|
+
document.getElementById('typingIndicator').classList.add('hidden');
|
|
986
|
+
input.disabled = false;
|
|
987
|
+
document.getElementById('sendBtn').disabled = false;
|
|
988
|
+
|
|
989
|
+
if (data.error) {
|
|
990
|
+
appendMessage('assistant', 'Error: ' + data.error);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
appendMessage('assistant', data.reply);
|
|
995
|
+
updateProgress(data.progress || 0, data.progressLabel || '', data.status || null);
|
|
996
|
+
|
|
997
|
+
if (data.done) {
|
|
998
|
+
isDone = true;
|
|
999
|
+
document.getElementById('inputArea').classList.add('hidden');
|
|
1000
|
+
document.getElementById('completeBar').classList.remove('hidden');
|
|
1001
|
+
} else {
|
|
1002
|
+
input.focus();
|
|
1003
|
+
}
|
|
1004
|
+
}).catch(function(err) {
|
|
1005
|
+
document.getElementById('typingIndicator').classList.add('hidden');
|
|
1006
|
+
input.disabled = false;
|
|
1007
|
+
document.getElementById('sendBtn').disabled = false;
|
|
1008
|
+
appendMessage('assistant', 'Connection error: ' + err.message);
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Send on Enter (Shift+Enter for newline)
|
|
1013
|
+
document.getElementById('userInput').addEventListener('keydown', function(e) {
|
|
1014
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1015
|
+
e.preventDefault();
|
|
1016
|
+
sendMessage();
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
</script>
|
|
1020
|
+
</body></html>`;
|
|
1021
|
+
}
|
|
1022
|
+
// ---------------------------------------------------------------------------
|
|
1023
|
+
// HTTP helpers
|
|
1024
|
+
// ---------------------------------------------------------------------------
|
|
1025
|
+
function parseFormBody(req) {
|
|
1026
|
+
return new Promise((resolve, reject) => {
|
|
1027
|
+
let body = '';
|
|
1028
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
1029
|
+
req.on('end', () => {
|
|
1030
|
+
const params = {};
|
|
1031
|
+
for (const pair of body.split('&')) {
|
|
1032
|
+
const idx = pair.indexOf('=');
|
|
1033
|
+
const k = idx > -1 ? pair.slice(0, idx) : pair;
|
|
1034
|
+
const v = idx > -1 ? pair.slice(idx + 1) : '';
|
|
1035
|
+
if (k)
|
|
1036
|
+
params[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' '));
|
|
1037
|
+
}
|
|
1038
|
+
resolve(params);
|
|
1039
|
+
});
|
|
1040
|
+
req.on('error', reject);
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
function parseMultipartBody(req) {
|
|
1044
|
+
return new Promise((resolve, reject) => {
|
|
1045
|
+
const ct = req.headers['content-type'] || '';
|
|
1046
|
+
const boundaryMatch = ct.match(/boundary=(.+)/);
|
|
1047
|
+
if (!boundaryMatch)
|
|
1048
|
+
return reject(new Error('No multipart boundary'));
|
|
1049
|
+
const boundary = '--' + boundaryMatch[1];
|
|
1050
|
+
const chunks = [];
|
|
1051
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
1052
|
+
req.on('end', () => {
|
|
1053
|
+
const body = Buffer.concat(chunks).toString();
|
|
1054
|
+
const parts = body.split(boundary).slice(1, -1);
|
|
1055
|
+
const params = {};
|
|
1056
|
+
for (const part of parts) {
|
|
1057
|
+
const nameMatch = part.match(/name="([^"]+)"/);
|
|
1058
|
+
if (!nameMatch)
|
|
1059
|
+
continue;
|
|
1060
|
+
const valStart = part.indexOf('\r\n\r\n');
|
|
1061
|
+
if (valStart === -1)
|
|
1062
|
+
continue;
|
|
1063
|
+
params[nameMatch[1]] = part.slice(valStart + 4).replace(/\r\n$/, '');
|
|
1064
|
+
}
|
|
1065
|
+
resolve(params);
|
|
1066
|
+
});
|
|
1067
|
+
req.on('error', reject);
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
function parseJsonBody(req) {
|
|
1071
|
+
return new Promise((resolve, reject) => {
|
|
1072
|
+
let body = '';
|
|
1073
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
1074
|
+
req.on('end', () => {
|
|
1075
|
+
try {
|
|
1076
|
+
resolve(JSON.parse(body));
|
|
1077
|
+
}
|
|
1078
|
+
catch (e) {
|
|
1079
|
+
reject(e);
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
req.on('error', reject);
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
function send(res, status, contentType, body) {
|
|
1086
|
+
res.writeHead(status, { 'Content-Type': contentType });
|
|
1087
|
+
res.end(body);
|
|
1088
|
+
}
|
|
1089
|
+
// ---------------------------------------------------------------------------
|
|
1090
|
+
// Agent status builder — provides rich status for the chat UI
|
|
1091
|
+
// ---------------------------------------------------------------------------
|
|
1092
|
+
const SECTION_CHIP_IDS = ['s1', 's2', 's3', 's4', 's5', 's6'];
|
|
1093
|
+
function buildAgentStatus(state) {
|
|
1094
|
+
const totalQuestions = AGENT_SECTIONS.reduce((sum, s) => sum + s.questions.length, 0);
|
|
1095
|
+
const answered = state.results.length;
|
|
1096
|
+
// Determine which sections are complete
|
|
1097
|
+
const completedSections = [];
|
|
1098
|
+
// Intro is done once we leave intro phase
|
|
1099
|
+
if (state.phase !== 'intro')
|
|
1100
|
+
completedSections.push('intro');
|
|
1101
|
+
// Check each interview section
|
|
1102
|
+
for (let i = 0; i < AGENT_SECTIONS.length; i++) {
|
|
1103
|
+
const sec = AGENT_SECTIONS[i];
|
|
1104
|
+
const sectionDone = sec.questions.every(q => state.results.some(r => r.questionId === q.id));
|
|
1105
|
+
if (sectionDone)
|
|
1106
|
+
completedSections.push(SECTION_CHIP_IDS[i]);
|
|
1107
|
+
}
|
|
1108
|
+
// Org readiness done if we're past it
|
|
1109
|
+
if (state.phase === 'closing' || state.phase === 'complete') {
|
|
1110
|
+
completedSections.push('readiness');
|
|
1111
|
+
}
|
|
1112
|
+
// Determine active chip
|
|
1113
|
+
let activeChip = 'intro';
|
|
1114
|
+
if (state.phase === 'interview') {
|
|
1115
|
+
activeChip = SECTION_CHIP_IDS[state.currentSectionIdx] || 's1';
|
|
1116
|
+
}
|
|
1117
|
+
else if (state.phase === 'org_readiness') {
|
|
1118
|
+
activeChip = 'readiness';
|
|
1119
|
+
}
|
|
1120
|
+
else if (state.phase === 'closing' || state.phase === 'complete') {
|
|
1121
|
+
activeChip = 'readiness'; // will be marked done
|
|
1122
|
+
}
|
|
1123
|
+
return {
|
|
1124
|
+
phase: state.phase,
|
|
1125
|
+
questionsAnswered: answered,
|
|
1126
|
+
totalQuestions,
|
|
1127
|
+
customerName: state.customerName || '',
|
|
1128
|
+
teamSize: state.teamSize || 0,
|
|
1129
|
+
fundingStage: state.fundingStage || '',
|
|
1130
|
+
completedSections,
|
|
1131
|
+
activeChip,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
// Server
|
|
1136
|
+
// ---------------------------------------------------------------------------
|
|
1137
|
+
function startServer(port) {
|
|
1138
|
+
const server = createServer(async (req, res) => {
|
|
1139
|
+
try {
|
|
1140
|
+
const url = req.url || '/';
|
|
1141
|
+
if (req.method === 'GET' && url === '/') {
|
|
1142
|
+
return send(res, 200, 'text/html', scanPage());
|
|
1143
|
+
}
|
|
1144
|
+
if (req.method === 'POST' && url === '/scan') {
|
|
1145
|
+
if (isEcsMode) {
|
|
1146
|
+
return send(res, 403, 'text/html', '<h1>Repo scanning is disabled in cloud mode. Please import scan results.</h1>');
|
|
1147
|
+
}
|
|
1148
|
+
const form = await parseFormBody(req);
|
|
1149
|
+
const repoPath = form.repoPath?.trim();
|
|
1150
|
+
const scanError = (title, detail) => send(res, 400, 'text/html', `<!DOCTYPE html><html><head><style>${PAGE_STYLE}</style></head><body><div class="page">
|
|
1151
|
+
<div class="card" style="border-left:4px solid #ef4444">
|
|
1152
|
+
<h2 style="color:#ef4444">${title}</h2>
|
|
1153
|
+
<p style="margin:12px 0;color:#475569">${detail}</p>
|
|
1154
|
+
<button onclick="history.back()">← Go Back</button>
|
|
1155
|
+
</div></div></body></html>`);
|
|
1156
|
+
if (!repoPath)
|
|
1157
|
+
return scanError('Repository path is required', 'Please enter the full path to a local git repository.');
|
|
1158
|
+
if (!existsSync(repoPath))
|
|
1159
|
+
return scanError('Path not found', `The directory <code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">${repoPath}</code> does not exist. Check for typos or trailing characters.`);
|
|
1160
|
+
try {
|
|
1161
|
+
const scan = await runScan(repoPath, { output: 'json' });
|
|
1162
|
+
return send(res, 200, 'text/html', scanResultsPage(scan));
|
|
1163
|
+
}
|
|
1164
|
+
catch (err) {
|
|
1165
|
+
return scanError('Scan failed', `Something went wrong while scanning <code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">${repoPath}</code>:<br><pre style="margin-top:8px;padding:12px;background:#f8fafc;border-radius:6px;font-size:13px;overflow-x:auto">${err.message || err}</pre>`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (req.method === 'POST' && url === '/export-json') {
|
|
1169
|
+
const form = await parseFormBody(req);
|
|
1170
|
+
const scan = JSON.parse(Buffer.from(form.scanData, 'base64').toString());
|
|
1171
|
+
res.writeHead(200, {
|
|
1172
|
+
'Content-Type': 'application/json',
|
|
1173
|
+
'Content-Disposition': `attachment; filename="${scan.repoName}-scan.json"`,
|
|
1174
|
+
});
|
|
1175
|
+
return res.end(JSON.stringify(scan, null, 2));
|
|
1176
|
+
}
|
|
1177
|
+
if (req.method === 'POST' && url === '/import') {
|
|
1178
|
+
const form = await parseMultipartBody(req);
|
|
1179
|
+
if (!form.scanData)
|
|
1180
|
+
return send(res, 400, 'text/plain', 'Empty scanData field');
|
|
1181
|
+
let scan;
|
|
1182
|
+
try {
|
|
1183
|
+
scan = JSON.parse(form.scanData);
|
|
1184
|
+
}
|
|
1185
|
+
catch (e) {
|
|
1186
|
+
return send(res, 400, 'text/plain', `Invalid JSON (${form.scanData.length} bytes): ${e.message}`);
|
|
1187
|
+
}
|
|
1188
|
+
if (!scan.repoName || !scan.categories) {
|
|
1189
|
+
return send(res, 400, 'text/html', '<h1>Invalid scan JSON — missing repoName or categories</h1>');
|
|
1190
|
+
}
|
|
1191
|
+
return send(res, 200, 'text/html', scanResultsPage(scan, true));
|
|
1192
|
+
}
|
|
1193
|
+
if (req.method === 'POST' && url === '/interview') {
|
|
1194
|
+
const form = await parseFormBody(req);
|
|
1195
|
+
const scan = JSON.parse(Buffer.from(form.scanData, 'base64').toString());
|
|
1196
|
+
return send(res, 200, 'text/html', interviewPage(scan));
|
|
1197
|
+
}
|
|
1198
|
+
if (req.method === 'POST' && url === '/report') {
|
|
1199
|
+
const form = await parseFormBody(req);
|
|
1200
|
+
const scan = JSON.parse(Buffer.from(form.scanData, 'base64').toString());
|
|
1201
|
+
// Server-side validation: check all question scores are filled
|
|
1202
|
+
const missing = [];
|
|
1203
|
+
if (!form.customerName?.trim())
|
|
1204
|
+
missing.push('Customer name');
|
|
1205
|
+
if (!form.saName?.trim())
|
|
1206
|
+
missing.push('Completed by');
|
|
1207
|
+
for (const sec of INTERVIEW_SECTIONS) {
|
|
1208
|
+
for (const q of sec.questions) {
|
|
1209
|
+
const val = form[q.id];
|
|
1210
|
+
if (val === undefined || val === null || val === '') {
|
|
1211
|
+
missing.push(q.label);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
if (missing.length > 0) {
|
|
1216
|
+
return send(res, 400, 'text/html', `<!DOCTYPE html><html><head><style>${PAGE_STYLE}</style></head><body><div class="page"><div class="card">
|
|
1217
|
+
<h2>Missing Required Fields</h2>
|
|
1218
|
+
<p>Please go back and complete the following:</p>
|
|
1219
|
+
<ul>${missing.map(m => `<li><strong>${m}</strong></li>`).join('')}</ul>
|
|
1220
|
+
<button onclick="history.back()">← Go Back</button>
|
|
1221
|
+
</div></div></body></html>`);
|
|
1222
|
+
}
|
|
1223
|
+
// Sum interview scores
|
|
1224
|
+
let interviewTotal = 0;
|
|
1225
|
+
for (const sec of INTERVIEW_SECTIONS) {
|
|
1226
|
+
for (const q of sec.questions) {
|
|
1227
|
+
interviewTotal += parseInt(form[q.id] || '0', 10);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
const org = {
|
|
1231
|
+
executiveSponsor: form.executiveSponsor === 'on',
|
|
1232
|
+
budgetAllocated: form.budgetAllocated === 'on',
|
|
1233
|
+
dedicatedOwner: form.dedicatedOwner === 'on',
|
|
1234
|
+
awsRelationship: form.awsRelationship === 'on',
|
|
1235
|
+
appropriateTeamSize: form.appropriateTeamSize === 'on',
|
|
1236
|
+
};
|
|
1237
|
+
const blended = computeBlended(scan.totalScore, scan.maxScore, interviewTotal, org);
|
|
1238
|
+
return send(res, 200, 'text/html', reportPage(scan, form, blended));
|
|
1239
|
+
}
|
|
1240
|
+
// --- Agent interview routes ---
|
|
1241
|
+
if (req.method === 'POST' && url === '/interview-agent') {
|
|
1242
|
+
const form = await parseFormBody(req);
|
|
1243
|
+
const scan = JSON.parse(Buffer.from(form.scanData, 'base64').toString());
|
|
1244
|
+
return send(res, 200, 'text/html', agentInterviewPage(scan));
|
|
1245
|
+
}
|
|
1246
|
+
if (req.method === 'POST' && url === '/api/agent/init') {
|
|
1247
|
+
const body = await parseJsonBody(req);
|
|
1248
|
+
const { sessionId, scanData } = body;
|
|
1249
|
+
// Pre-flight check: verify Bedrock access before starting
|
|
1250
|
+
const check = await checkBedrockAccess(body.modelId, body.region);
|
|
1251
|
+
if (!check.ok) {
|
|
1252
|
+
const instructions = {
|
|
1253
|
+
sdk_missing: `The AWS SDK is not installed.<br><br>Run this in the <code>cli/</code> directory:<pre style="margin:8px 0;padding:10px;background:#1e293b;color:#e2e8f0;border-radius:6px">npm install @aws-sdk/client-bedrock-runtime</pre>Then restart the server and try again.`,
|
|
1254
|
+
no_credentials: `No AWS credentials found. The agent needs credentials with Bedrock access.<br><br><strong>Option 1 — AWS CLI profile:</strong><pre style="margin:8px 0;padding:10px;background:#1e293b;color:#e2e8f0;border-radius:6px">aws configure</pre><strong>Option 2 — Environment variables:</strong><pre style="margin:8px 0;padding:10px;background:#1e293b;color:#e2e8f0;border-radius:6px">export AWS_ACCESS_KEY_ID=your-key\nexport AWS_SECRET_ACCESS_KEY=your-secret\nexport AWS_REGION=us-west-2</pre><strong>Option 3 — SSO:</strong><pre style="margin:8px 0;padding:10px;background:#1e293b;color:#e2e8f0;border-radius:6px">aws sso login --profile your-profile</pre>After configuring credentials, restart the server and try again.`,
|
|
1255
|
+
no_model_access: `Bedrock model access denied.<br><br><strong>To enable model access:</strong><ol style="margin:8px 0;padding-left:20px"><li>Go to the <a href="https://console.aws.amazon.com/bedrock/home#/modelaccess" target="_blank" style="color:#0066ff">Amazon Bedrock Model Access</a> page</li><li>Click "Manage model access"</li><li>Enable <strong>Anthropic → Claude Sonnet 4.6</strong> (or the model you want to use)</li><li>Wait for access to be granted (usually instant)</li></ol>Then restart the server and try again.<br><br><span style="color:#64748b;font-size:12px">Error: ${check.error}</span>`,
|
|
1256
|
+
wrong_region: `The model is not available in the configured region.<br><br>Try a different region by setting <code>AWS_REGION</code>:<pre style="margin:8px 0;padding:10px;background:#1e293b;color:#e2e8f0;border-radius:6px">export AWS_REGION=us-east-1</pre>Or use a cross-region inference ID like <code>us.anthropic.claude-sonnet-4-6</code>.<br><br><span style="color:#64748b;font-size:12px">Error: ${check.error}</span>`,
|
|
1257
|
+
unknown: `An unexpected error occurred while connecting to Bedrock.<br><br><pre style="margin:8px 0;padding:10px;background:#1e293b;color:#e2e8f0;border-radius:6px;white-space:pre-wrap">${check.error}</pre>Check your AWS credentials and Bedrock model access, then restart the server.`,
|
|
1258
|
+
};
|
|
1259
|
+
return send(res, 200, 'application/json', JSON.stringify({
|
|
1260
|
+
setupError: true,
|
|
1261
|
+
errorType: check.errorType,
|
|
1262
|
+
instructions: instructions[check.errorType || 'unknown'] || instructions.unknown,
|
|
1263
|
+
}));
|
|
1264
|
+
}
|
|
1265
|
+
try {
|
|
1266
|
+
const scan = JSON.parse(Buffer.from(scanData, 'base64').toString());
|
|
1267
|
+
const session = createSession(scan);
|
|
1268
|
+
agentSessions.set(sessionId, session);
|
|
1269
|
+
// Send the first message (greeting)
|
|
1270
|
+
const result = await processMessage(session, '', body.modelId, body.region);
|
|
1271
|
+
agentSessions.set(sessionId, result.state);
|
|
1272
|
+
const totalQuestions = AGENT_SECTIONS.reduce((sum, s) => sum + s.questions.length, 0);
|
|
1273
|
+
const answered = result.state.results.length;
|
|
1274
|
+
const progress = (answered / totalQuestions) * 100;
|
|
1275
|
+
return send(res, 200, 'application/json', JSON.stringify({
|
|
1276
|
+
reply: result.reply,
|
|
1277
|
+
done: result.done,
|
|
1278
|
+
progress,
|
|
1279
|
+
progressLabel: 'Introduction',
|
|
1280
|
+
status: buildAgentStatus(result.state),
|
|
1281
|
+
}));
|
|
1282
|
+
}
|
|
1283
|
+
catch (err) {
|
|
1284
|
+
return send(res, 500, 'application/json', JSON.stringify({
|
|
1285
|
+
error: err.message || 'Failed to initialize agent session',
|
|
1286
|
+
}));
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (req.method === 'POST' && url === '/api/agent/chat') {
|
|
1290
|
+
const body = await parseJsonBody(req);
|
|
1291
|
+
const { sessionId, message, modelId, region } = body;
|
|
1292
|
+
const session = agentSessions.get(sessionId);
|
|
1293
|
+
if (!session) {
|
|
1294
|
+
return send(res, 400, 'application/json', JSON.stringify({
|
|
1295
|
+
error: 'Session not found. Please refresh and start over.',
|
|
1296
|
+
}));
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
const result = await processMessage(session, message, modelId, region);
|
|
1300
|
+
agentSessions.set(sessionId, result.state);
|
|
1301
|
+
const totalQuestions = AGENT_SECTIONS.reduce((sum, s) => sum + s.questions.length, 0);
|
|
1302
|
+
const answered = result.state.results.length;
|
|
1303
|
+
const progress = (answered / totalQuestions) * 100;
|
|
1304
|
+
let progressLabel = 'Introduction';
|
|
1305
|
+
if (result.state.phase === 'interview') {
|
|
1306
|
+
const sec = AGENT_SECTIONS[result.state.currentSectionIdx];
|
|
1307
|
+
progressLabel = sec ? `${sec.name} — Q${result.state.currentQuestionIdx + 1}/${sec.questions.length}` : 'Interview';
|
|
1308
|
+
}
|
|
1309
|
+
else if (result.state.phase === 'org_readiness') {
|
|
1310
|
+
progressLabel = 'Org Readiness';
|
|
1311
|
+
}
|
|
1312
|
+
else if (result.state.phase === 'closing') {
|
|
1313
|
+
progressLabel = 'Closing';
|
|
1314
|
+
}
|
|
1315
|
+
else if (result.state.phase === 'complete') {
|
|
1316
|
+
progressLabel = 'Complete';
|
|
1317
|
+
}
|
|
1318
|
+
return send(res, 200, 'application/json', JSON.stringify({
|
|
1319
|
+
reply: result.reply,
|
|
1320
|
+
done: result.done,
|
|
1321
|
+
progress: Math.min(100, progress),
|
|
1322
|
+
progressLabel,
|
|
1323
|
+
status: buildAgentStatus(result.state),
|
|
1324
|
+
}));
|
|
1325
|
+
}
|
|
1326
|
+
catch (err) {
|
|
1327
|
+
return send(res, 500, 'application/json', JSON.stringify({
|
|
1328
|
+
error: err.message || 'Agent processing error',
|
|
1329
|
+
}));
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (req.method === 'POST' && url === '/agent-report') {
|
|
1333
|
+
const form = await parseFormBody(req);
|
|
1334
|
+
const session = agentSessions.get(form.sessionId);
|
|
1335
|
+
if (!session) {
|
|
1336
|
+
return send(res, 400, 'text/html', `<h1>Session expired. Please run the interview again.</h1>`);
|
|
1337
|
+
}
|
|
1338
|
+
// Convert agent results to the same format as the manual form
|
|
1339
|
+
const formData = agentResultsToFormData(session);
|
|
1340
|
+
const scan = session.scanData;
|
|
1341
|
+
let interviewTotal = 0;
|
|
1342
|
+
for (const sec of INTERVIEW_SECTIONS) {
|
|
1343
|
+
for (const q of sec.questions) {
|
|
1344
|
+
interviewTotal += parseInt(formData[q.id] || '0', 10);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
const org = {
|
|
1348
|
+
executiveSponsor: formData.executiveSponsor === 'on',
|
|
1349
|
+
budgetAllocated: formData.budgetAllocated === 'on',
|
|
1350
|
+
dedicatedOwner: formData.dedicatedOwner === 'on',
|
|
1351
|
+
awsRelationship: formData.awsRelationship === 'on',
|
|
1352
|
+
appropriateTeamSize: formData.appropriateTeamSize === 'on',
|
|
1353
|
+
};
|
|
1354
|
+
const blended = computeBlended(scan.totalScore, scan.maxScore, interviewTotal, org);
|
|
1355
|
+
return send(res, 200, 'text/html', reportPage(scan, formData, blended));
|
|
1356
|
+
}
|
|
1357
|
+
send(res, 404, 'text/html', '<h1>Not Found</h1>');
|
|
1358
|
+
}
|
|
1359
|
+
catch (err) {
|
|
1360
|
+
console.error('Server error:', err);
|
|
1361
|
+
send(res, 500, 'text/html', `<div class="page"><div class="card"><h2>Error</h2><pre>${err.message || err}</pre></div></div>`);
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
const host = isEcsMode ? '0.0.0.0' : 'localhost';
|
|
1365
|
+
server.listen(port, host, () => {
|
|
1366
|
+
const url = isEcsMode ? `http://0.0.0.0:${port}` : `http://localhost:${port}`;
|
|
1367
|
+
console.log(`\n PRISM D1 Assessment Web UI${isEcsMode ? ' (ECS Mode - Import Only)' : ''}`);
|
|
1368
|
+
console.log(` Running at: ${url}\n`);
|
|
1369
|
+
// Try to open browser (skip in ECS)
|
|
1370
|
+
if (!isEcsMode) {
|
|
1371
|
+
try {
|
|
1372
|
+
const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1373
|
+
execSync(`${open} http://localhost:${port}`, { stdio: 'ignore' });
|
|
1374
|
+
}
|
|
1375
|
+
catch { /* ignore if browser can't open */ }
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
// ---------------------------------------------------------------------------
|
|
1380
|
+
// CLI command export
|
|
1381
|
+
// ---------------------------------------------------------------------------
|
|
1382
|
+
export { startServer };
|
|
1383
|
+
export default {
|
|
1384
|
+
description: 'Launch the assessment web interface',
|
|
1385
|
+
options: [
|
|
1386
|
+
{ flags: '-p, --port <number>', description: 'Port to listen on', default: '3120' },
|
|
1387
|
+
],
|
|
1388
|
+
action(options) {
|
|
1389
|
+
const port = parseInt(options.port, 10) || 3120;
|
|
1390
|
+
startServer(port);
|
|
1391
|
+
},
|
|
1392
|
+
};
|
|
1393
|
+
//# sourceMappingURL=web.js.map
|