Compare commits

..

2 Commits

Author SHA1 Message Date
posimai 7210c8301c feat(together): AI要約の手動再試行エンドポイント追加(failed/skipped/summary欠落に対応) 2026-04-21 14:14:04 +09:00
posimai 01d4ee926a fix(pc-audit): 概要を2カラムグリッドに変更、全体幅をフル幅に修正
viewer: .summary-grid を2カラムに。PC名のみ全幅(grid-column span)。
max-width 制限を撤廃し右余白を解消。

PS1: 管理者判定を net localgroup フォールバック含む3段階に強化。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 12:48:34 +09:00
3 changed files with 88 additions and 9 deletions

View File

@ -2661,6 +2661,56 @@ ${excerpt}
} }
} }
// POST /together/share/:shareId/rearchive — AI要約の手動再試行
r.post('/together/share/:shareId/rearchive', async (req, res) => {
const shareId = parseInt(req.params.shareId, 10);
if (Number.isNaN(shareId)) return res.status(400).json({ error: 'invalid shareId' });
const username = normalizeTogetherUsername(req.query.u || req.body?.username);
const jwtUserId = getTogetherJwtUserId(req);
try {
const shareRow = await pool.query(
'SELECT id, url, full_content, archive_status, summary, group_id FROM together_shares WHERE id=$1',
[shareId]
);
if (shareRow.rows.length === 0) return res.status(404).json({ error: '見つかりません' });
const share = shareRow.rows[0];
if (!(await togetherEnsureMember(pool, res, share.group_id, username, jwtUserId))) return;
if (!checkRateLimit(`rearchive_${shareId}`, 'global', 3, 60 * 60 * 1000)) {
return res.status(429).json({ error: '再試行の上限に達しました。1時間後に再試行してください' });
}
// full_content が既にあるdone だが summary なし)場合は Gemini のみ再実行
if (share.archive_status === 'done' && share.full_content && !share.summary) {
if (!togetherGenAI) return res.status(503).json({ error: 'AI が設定されていません' });
await pool.query(`UPDATE together_shares SET archive_status='pending' WHERE id=$1`, [shareId]);
res.json({ ok: true, status: 'pending' });
try {
const bodyStart = share.full_content.search(/^#{1,2}\s/m);
const excerpt = (bodyStart >= 0 ? share.full_content.slice(bodyStart) : share.full_content).slice(0, 4000);
const model = togetherGenAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
const prompt = `以下の記事を分析して、JSONのみを返してくださいコードブロック不要\n\n{"summary":"1〜2文の日本語要約","tags":["タグ1","タグ2","タグ3"]}\n\n- summary: 読者が読む価値があるかを判断できる1〜2文\n- tags: 内容を表す具体的な日本語タグを2〜4個。「その他」は絶対に使わないこと\n\n記事:\n${excerpt}`;
const timeoutP = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000));
const result = await Promise.race([model.generateContent(prompt), timeoutP]);
const raw = result.response.text().trim();
let summary = null, tags = [];
try { const p = JSON.parse(raw); summary = (p.summary || '').slice(0, 300); tags = Array.isArray(p.tags) ? p.tags.slice(0, 4).map(t => String(t).slice(0, 20)) : []; }
catch { summary = raw.slice(0, 300); }
await pool.query(`UPDATE together_shares SET summary=$1, tags=$2, archive_status='done' WHERE id=$3`, [summary, tags, shareId]);
} catch (e) {
console.error('[rearchive gemini]', shareId, e.message);
await pool.query(`UPDATE together_shares SET archive_status='done' WHERE id=$1`, [shareId]);
}
} else {
// failed / skipped — フル再アーカイブ
await pool.query(`UPDATE together_shares SET archive_status='pending' WHERE id=$1`, [shareId]);
res.json({ ok: true, status: 'pending' });
archiveShare(shareId, share.url);
}
} catch (e) {
console.error('[rearchive]', e.message);
if (!res.headersSent) res.status(500).json({ error: 'Internal server error' });
}
});
// POST /together/groups — グループ作成 // POST /together/groups — グループ作成
r.post('/together/groups', async (req, res) => { r.post('/together/groups', async (req, res) => {
const { name, username } = req.body || {}; const { name, username } = req.body || {};

View File

@ -1,4 +1,4 @@
#Requires -Version 5.1 #Requires -Version 5.1
<# <#
.SYNOPSIS .SYNOPSIS
Read-only local PC audit (developer / AI-tooling focused). No writes except report output. Read-only local PC audit (developer / AI-tooling focused). No writes except report output.
@ -8,7 +8,7 @@
Directory for reports (created if missing). Default: tools/pc-audit/out under repo root. Directory for reports (created if missing). Default: tools/pc-audit/out under repo root.
.PARAMETER ProjectRoot .PARAMETER ProjectRoot
Repo root to scan for env-like files. Default: detected from script location. Scan root for env-like files. In monorepo layout: repo root. In portable ZIP: defaults to USERPROFILE.
.PARAMETER Format .PARAMETER Format
json | md | both json | md | both
@ -27,8 +27,31 @@ param(
$ErrorActionPreference = "SilentlyContinue" $ErrorActionPreference = "SilentlyContinue"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = (Resolve-Path (Join-Path $scriptDir "..\..")).Path $defaultRepoFromLayout = (Resolve-Path (Join-Path $scriptDir "..\..")).Path
if (-not $ProjectRoot) { $ProjectRoot = $repoRoot } $monorepoMarker = Join-Path $defaultRepoFromLayout "package.json"
$expectedScriptInRepo = Join-Path $defaultRepoFromLayout "tools\pc-audit\Invoke-PcAudit.ps1"
$currentScriptPath = $MyInvocation.MyCommand.Path
if (-not $currentScriptPath) { $currentScriptPath = (Join-Path $scriptDir "Invoke-PcAudit.ps1") }
$isMonorepoLayout = $false
if ((Test-Path -LiteralPath $monorepoMarker) -and (Test-Path -LiteralPath $expectedScriptInRepo)) {
try {
$expPath = (Resolve-Path -LiteralPath $expectedScriptInRepo).Path
$curPath = (Resolve-Path -LiteralPath $currentScriptPath).Path
if ($expPath -eq $curPath) { $isMonorepoLayout = $true }
} catch {}
}
if ($isMonorepoLayout) {
$repoRoot = $defaultRepoFromLayout
} else {
$repoRoot = $scriptDir
}
if (-not $ProjectRoot) {
if ($isMonorepoLayout) {
$ProjectRoot = $repoRoot
} else {
$ProjectRoot = $env:USERPROFILE
}
}
if (-not $OutDir) { $OutDir = Join-Path $scriptDir "out" } if (-not $OutDir) { $OutDir = Join-Path $scriptDir "out" }
if (-not (Test-Path -LiteralPath $OutDir)) { if (-not (Test-Path -LiteralPath $OutDir)) {
@ -858,7 +881,11 @@ Write-Host " latest.json: $latestPath"
if ($Format -eq "md" -or $Format -eq "both") { if ($Format -eq "md" -or $Format -eq "both") {
Write-Host " MD: $mdPath" Write-Host " MD: $mdPath"
} }
$viewerPath = Join-Path $repoRoot "tools\pc-audit\report-viewer.html" if ($isMonorepoLayout) {
$viewerPath = Join-Path $repoRoot "tools\pc-audit\report-viewer.html"
} else {
$viewerPath = Join-Path $scriptDir "report-viewer.html"
}
Write-Host "" Write-Host ""
Write-Host "ブラウザで見る: エクスプローラーで次の HTML を開き、同じく out フォルダの JSON を選んでください。" Write-Host "ブラウザで見る: エクスプローラーで次の HTML を開き、同じく out フォルダの JSON を選んでください。"
Write-Host " $viewerPath" Write-Host " $viewerPath"

View File

@ -36,7 +36,7 @@
} }
/* Layout */ /* Layout */
.page { max-width: 1280px; padding: 2rem 1.5rem 4rem; } .page { padding: 2rem 1.5rem 4rem; }
.header { margin-bottom: 2rem; } .header { margin-bottom: 2rem; }
.header h1 { font-size: 1.1rem; font-weight: 600; letter-spacing: 0.05em; color: var(--text2); } .header h1 { font-size: 1.1rem; font-weight: 600; letter-spacing: 0.05em; color: var(--text2); }
@ -90,12 +90,13 @@
text-transform: uppercase; color: var(--text3); margin: 0 0 0.6rem; text-transform: uppercase; color: var(--text3); margin: 0 0 0.6rem;
} }
/* Metric grid */ /* Metric grid — 2-col, PC名 spans full width */
.summary-grid { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.75rem; } .summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; }
.metric { .metric {
background: var(--surface); border: 1px solid var(--border); background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); padding: 0.7rem 0.9rem;
} }
.metric.full { grid-column: 1 / -1; }
.metric-label { font-size: 0.7rem; color: var(--text3); margin-bottom: 0.15rem; } .metric-label { font-size: 0.7rem; color: var(--text3); margin-bottom: 0.15rem; }
.metric-value { font-size: 0.9rem; font-weight: 600; color: var(--text); word-break: break-all; } .metric-value { font-size: 0.9rem; font-weight: 600; color: var(--text); word-break: break-all; }
@ -297,7 +298,8 @@
var html = '<div class="summary-grid">'; var html = '<div class="summary-grid">';
items.forEach(function (it) { items.forEach(function (it) {
html += '<div class="metric"><div class="metric-label">' + esc(it.label) + '</div><div class="metric-value">' + esc(it.value) + '</div></div>'; var full = (it.label === "PC 名" || it.label === "モード") ? ' full' : '';
html += '<div class="metric' + full + '"><div class="metric-label">' + esc(it.label) + '</div><div class="metric-value">' + esc(it.value) + '</div></div>';
}); });
html += '</div>'; html += '</div>';
document.getElementById("summary").innerHTML = html; document.getElementById("summary").innerHTML = html;