Initial commit: prompt template manager

React + Tailwind frontend, FastAPI backend, settings.json storage.
Supports preset CRUD, live variable filling, and AI rewrite via OpenAI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Space-Banane
2026-06-26 15:38:17 +02:00
commit 20f06d0a57
21 changed files with 3537 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
frontend/node_modules/
frontend/dist/
*.tmp
__pycache__/
*.pyc
.venv/
View File
+120
View File
@@ -0,0 +1,120 @@
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from openai import AsyncOpenAI
from pydantic import BaseModel
import backend.storage as storage
from backend.models import extract_vars
DIST = Path(__file__).parent.parent / "frontend" / "dist"
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1")
app = FastAPI()
# ── Models ────────────────────────────────────────────────────────────────────
class PresetBody(BaseModel):
name: str
description: str = ""
template: str
class SettingsBody(BaseModel):
openai_api_key: str
class RewriteBody(BaseModel):
prompt: str
# ── Presets ───────────────────────────────────────────────────────────────────
@app.get("/api/presets")
def get_presets():
return {"presets": storage.list_presets()}
@app.post("/api/presets", status_code=201)
def create_preset(body: PresetBody):
variables = extract_vars(body.template)
return storage.create_preset(body.name, body.description, body.template, variables)
@app.put("/api/presets/{preset_id}")
def update_preset(preset_id: str, body: PresetBody):
variables = extract_vars(body.template)
result = storage.update_preset(preset_id, {
"name": body.name,
"description": body.description,
"template": body.template,
"variables": variables,
})
if result is None:
raise HTTPException(status_code=404, detail="Preset not found")
return result
@app.delete("/api/presets/{preset_id}")
def delete_preset(preset_id: str):
if not storage.delete_preset(preset_id):
raise HTTPException(status_code=404, detail="Preset not found")
return {"ok": True}
# ── Settings ──────────────────────────────────────────────────────────────────
@app.get("/api/settings")
def get_settings():
key = storage.get_openai_key()
return {"openai_api_key_set": bool(key)}
@app.post("/api/settings")
def update_settings(body: SettingsBody):
storage.set_openai_key(body.openai_api_key)
return {"ok": True}
# ── AI Rewrite ────────────────────────────────────────────────────────────────
@app.post("/api/rewrite")
async def rewrite_prompt(body: RewriteBody):
key = storage.get_openai_key()
if not key:
raise HTTPException(status_code=400, detail="OpenAI API key not configured")
client = AsyncOpenAI(api_key=key)
try:
response = await client.chat.completions.create(
model=OPENAI_MODEL,
messages=[
{
"role": "system",
"content": (
"You are a prompt engineer. Rewrite the user's prompt to be clearer, "
"more specific, and more effective for an AI assistant. "
"Return only the rewritten prompt, no explanation."
),
},
{"role": "user", "content": body.prompt},
],
)
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
return {"rewritten": response.choices[0].message.content}
# ── SPA static serving (must be last) ────────────────────────────────────────
if DIST.exists():
app.mount("/assets", StaticFiles(directory=DIST / "assets"), name="assets")
@app.get("/{full_path:path}")
def serve_spa(full_path: str):
return FileResponse(DIST / "index.html")
+13
View File
@@ -0,0 +1,13 @@
import re
VAR_RE = re.compile(r'\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}')
def extract_vars(template: str) -> list[str]:
seen = set()
result = []
for m in VAR_RE.finditer(template):
if m.group(1) not in seen:
seen.add(m.group(1))
result.append(m.group(1))
return result
+73
View File
@@ -0,0 +1,73 @@
import json
import secrets
from datetime import datetime, timezone
from pathlib import Path
SETTINGS_PATH = Path(__file__).parent.parent / "settings.json"
def _now():
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _read():
if not SETTINGS_PATH.exists():
return {"openai_api_key": "", "presets": []}
return json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
def _write(data):
tmp = SETTINGS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(SETTINGS_PATH)
def list_presets():
return _read()["presets"]
def create_preset(name, description, template, variables):
data = _read()
preset = {
"id": secrets.token_hex(4),
"name": name,
"description": description,
"template": template,
"variables": variables,
"created_at": _now(),
"updated_at": _now(),
}
data["presets"].append(preset)
_write(data)
return preset
def update_preset(preset_id, fields):
data = _read()
for p in data["presets"]:
if p["id"] == preset_id:
p.update(fields)
p["updated_at"] = _now()
_write(data)
return p
return None
def delete_preset(preset_id):
data = _read()
before = len(data["presets"])
data["presets"] = [p for p in data["presets"] if p["id"] != preset_id]
if len(data["presets"]) == before:
return False
_write(data)
return True
def get_openai_key():
return _read().get("openai_api_key", "")
def set_openai_key(key: str):
data = _read()
data["openai_api_key"] = key
_write(data)
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prompt Gen</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2736
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "prompt-gen-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"vite": "^6.3.5"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} }
}
+129
View File
@@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react'
import TemplateList from './components/TemplateList.jsx'
import TemplateEditor from './components/TemplateEditor.jsx'
import PromptRunner from './components/PromptRunner.jsx'
import Settings from './components/Settings.jsx'
async function api(method, path, body) {
const res = await fetch(path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail)
}
return res.json()
}
export default function App() {
const [presets, setPresets] = useState([])
const [selected, setSelected] = useState(null)
const [mode, setMode] = useState('empty') // 'empty' | 'run' | 'edit' | 'new'
const [apiKeySet, setApiKeySet] = useState(false)
const [showSettings, setShowSettings] = useState(false)
useEffect(() => {
api('GET', '/api/presets').then(d => {
setPresets(d.presets)
if (d.presets.length > 0) { setSelected(d.presets[0]); setMode('run') }
})
api('GET', '/api/settings').then(d => setApiKeySet(d.openai_api_key_set))
}, [])
function selectPreset(p) {
setSelected(p)
setMode('run')
}
function startNew() {
setSelected(null)
setMode('new')
}
async function handleSave(body) {
if (mode === 'new') {
const created = await api('POST', '/api/presets', body)
setPresets(prev => [...prev, created])
setSelected(created)
setMode('run')
} else {
const updated = await api('PUT', `/api/presets/${selected.id}`, body)
setPresets(prev => prev.map(p => p.id === updated.id ? updated : p))
setSelected(updated)
setMode('run')
}
}
async function handleDelete() {
if (!selected) return
await api('DELETE', `/api/presets/${selected.id}`)
const next = presets.filter(p => p.id !== selected.id)
setPresets(next)
if (next.length > 0) { setSelected(next[0]); setMode('run') }
else { setSelected(null); setMode('empty') }
}
function handleCancel() {
if (selected) setMode('run')
else setMode('empty')
}
return (
<div className="flex h-screen bg-zinc-950 text-zinc-100 overflow-hidden">
{/* Sidebar */}
<TemplateList
presets={presets}
selectedId={selected?.id}
onSelect={selectPreset}
onNew={startNew}
/>
{/* Main area */}
<main className="flex-1 flex flex-col min-w-0">
{/* Topbar */}
<header className="flex items-center justify-end px-4 py-2 border-b border-zinc-800 bg-zinc-900">
<button
onClick={() => setShowSettings(true)}
title="Settings"
className="p-1.5 rounded-md text-zinc-400 hover:text-white hover:bg-zinc-700 transition-colors text-lg"
>
</button>
</header>
<div className="flex-1 flex overflow-hidden">
{mode === 'empty' && (
<div className="flex-1 flex items-center justify-center text-zinc-500 text-sm">
Create a preset to get started.
</div>
)}
{mode === 'run' && selected && (
<PromptRunner
preset={selected}
onEdit={() => setMode('edit')}
apiKeySet={apiKeySet}
/>
)}
{(mode === 'edit' || mode === 'new') && (
<TemplateEditor
preset={mode === 'edit' ? selected : null}
onSave={handleSave}
onCancel={handleCancel}
onDelete={mode === 'edit' ? handleDelete : undefined}
/>
)}
</div>
</main>
{showSettings && (
<Settings
apiKeySet={apiKeySet}
onSaved={() => setApiKeySet(true)}
onClose={() => setShowSettings(false)}
/>
)}
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
import React, { useState, useMemo } from 'react'
import { parseVars, fillTemplate } from '../lib/parseVars.js'
export default function PromptRunner({ preset, onEdit, apiKeySet }) {
const vars = useMemo(() => parseVars(preset.template), [preset.template])
const [values, setValues] = useState({})
const [copied, setCopied] = useState(false)
const [rewriting, setRewriting] = useState(false)
const [rewrittenOutput, setRewrittenOutput] = useState(null)
const [rewriteError, setRewriteError] = useState('')
const baseOutput = fillTemplate(preset.template, values)
const output = rewrittenOutput ?? baseOutput
function setValue(key, val) {
setValues(prev => ({ ...prev, [key]: val }))
setRewrittenOutput(null)
}
async function handleCopy() {
await navigator.clipboard.writeText(output)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
async function handleRewrite() {
setRewriting(true)
setRewriteError('')
try {
const res = await fetch('/api/rewrite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: baseOutput }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.detail)
setRewrittenOutput(data.rewritten)
} catch (e) {
setRewriteError(e.message)
} finally {
setRewriting(false)
}
}
return (
<div className="flex-1 flex flex-col p-6 gap-5 overflow-y-auto">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-white">{preset.name}</h2>
{preset.description && <p className="text-sm text-zinc-400 mt-0.5">{preset.description}</p>}
</div>
<button
onClick={onEdit}
className="px-3 py-1.5 text-sm rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
>
Edit
</button>
</div>
{vars.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{vars.map(v => (
<div key={v} className="flex flex-col gap-1">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">{v.replace(/_/g, ' ')}</label>
<input
value={values[v] ?? ''}
onChange={e => setValue(v, e.target.value)}
placeholder={v}
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
))}
</div>
)}
<div className="flex flex-col gap-2 flex-1">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">
Output {rewrittenOutput && <span className="text-indigo-400 normal-case">(AI rewritten)</span>}
</label>
<div className="flex gap-2">
{rewrittenOutput && (
<button
onClick={() => setRewrittenOutput(null)}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Reset
</button>
)}
<button
onClick={handleRewrite}
disabled={rewriting || !apiKeySet || !baseOutput.trim()}
title={!apiKeySet ? 'Add OpenAI key in Settings ⚙' : 'Rewrite with AI'}
className="px-3 py-1 text-xs rounded-md bg-violet-800 hover:bg-violet-700 text-violet-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{rewriting ? 'Rewriting…' : '✦ Rewrite with AI'}
</button>
<button
onClick={handleCopy}
className="px-3 py-1 text-xs rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
{rewriteError && <p className="text-red-400 text-xs">{rewriteError}</p>}
<textarea
readOnly
value={output}
className="flex-1 min-h-48 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 resize-none font-mono focus:outline-none"
/>
</div>
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
import React, { useState } from 'react'
export default function Settings({ apiKeySet, onSaved, onClose }) {
const [key, setKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
async function handleSave() {
if (!key.trim()) { setError('Enter an API key'); return }
setSaving(true)
setError('')
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ openai_api_key: key.trim() }),
})
if (!res.ok) { const d = await res.json(); throw new Error(d.detail) }
setSaved(true)
onSaved()
setTimeout(() => setSaved(false), 2000)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md shadow-xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-white font-semibold text-base">Settings</h2>
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 text-xl leading-none">×</button>
</div>
<div className="flex flex-col gap-1 mb-4">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">OpenAI API Key</label>
<p className="text-xs text-zinc-500 mb-1.5">
{apiKeySet ? 'A key is already saved. Enter a new one to replace it.' : 'Required for AI rewrite feature.'}
</p>
<input
type="password"
value={key}
onChange={e => setKey(e.target.value)}
placeholder="sk-..."
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{error && <p className="text-red-400 text-xs mb-3">{error}</p>}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 text-sm rounded-md bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors disabled:opacity-50"
>
{saved ? 'Saved!' : saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import React, { useState, useEffect } from 'react'
import { parseVars } from '../lib/parseVars.js'
export default function TemplateEditor({ preset, onSave, onCancel, onDelete }) {
const isNew = !preset
const [name, setName] = useState(preset?.name ?? '')
const [description, setDescription] = useState(preset?.description ?? '')
const [template, setTemplate] = useState(preset?.template ?? '')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
setName(preset?.name ?? '')
setDescription(preset?.description ?? '')
setTemplate(preset?.template ?? '')
setError('')
}, [preset])
const detectedVars = parseVars(template)
async function handleSave() {
if (!name.trim()) { setError('Name is required'); return }
if (!template.trim()) { setError('Template is required'); return }
setSaving(true)
setError('')
try {
await onSave({ name: name.trim(), description: description.trim(), template })
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
return (
<div className="flex-1 flex flex-col p-6 gap-4 overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">{isNew ? 'New Preset' : 'Edit Preset'}</h2>
<div className="flex gap-2">
{!isNew && (
<button
onClick={onDelete}
className="px-3 py-1.5 text-sm rounded-md bg-red-900 hover:bg-red-700 text-red-200 transition-colors"
>
Delete
</button>
)}
<button
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 text-sm rounded-md bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">Name</label>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="My prompt template"
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">Description</label>
<input
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Optional short description"
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="flex flex-col gap-1 flex-1">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">
Template use <code className="text-indigo-400">{'{{'+'variable'+'}}'}</code> for inputs
</label>
<textarea
value={template}
onChange={e => setTemplate(e.target.value)}
placeholder="e.g. Resolve {{issue_id}} in {{repo}}. Create the PR as {{author}}."
className="flex-1 min-h-48 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none font-mono"
/>
{detectedVars.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
<span className="text-xs text-zinc-500">Detected variables:</span>
{detectedVars.map(v => (
<span key={v} className="text-xs bg-indigo-900 text-indigo-300 rounded px-2 py-0.5">
{v}
</span>
))}
</div>
)}
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
import React from 'react'
export default function TemplateList({ presets, selectedId, onSelect, onNew }) {
return (
<aside className="w-64 shrink-0 flex flex-col border-r border-zinc-700 bg-zinc-900 h-full">
<div className="p-3 border-b border-zinc-700">
<button
onClick={onNew}
className="w-full py-2 px-3 rounded-md bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors"
>
+ New Preset
</button>
</div>
<div className="flex-1 overflow-y-auto">
{presets.length === 0 && (
<p className="p-4 text-zinc-500 text-sm">No presets yet. Create one!</p>
)}
{presets.map(p => (
<button
key={p.id}
onClick={() => onSelect(p)}
className={`w-full text-left px-4 py-3 border-b border-zinc-800 transition-colors ${
selectedId === p.id
? 'bg-zinc-700 text-white'
: 'text-zinc-300 hover:bg-zinc-800'
}`}
>
<div className="text-sm font-medium truncate">{p.name}</div>
{p.description && (
<div className="text-xs text-zinc-500 truncate mt-0.5">{p.description}</div>
)}
{p.variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{p.variables.map(v => (
<span key={v} className="text-xs bg-zinc-700 text-indigo-300 rounded px-1.5 py-0.5">
{v}
</span>
))}
</div>
)}
</button>
))}
</div>
</aside>
)
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+19
View File
@@ -0,0 +1,19 @@
const VAR_RE = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g
export function parseVars(template) {
const seen = new Set()
const vars = []
let m
VAR_RE.lastIndex = 0
while ((m = VAR_RE.exec(template)) !== null) {
if (!seen.has(m[1])) {
seen.add(m[1])
vars.push(m[1])
}
}
return vars
}
export function fillTemplate(template, values) {
return template.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (_, k) => values[k] ?? `{{${k}}}`)
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(<App />)
+5
View File
@@ -0,0 +1,5 @@
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: { extend: {} },
plugins: [],
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8001'
}
},
build: {
outDir: 'dist'
}
})
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.111.0
uvicorn[standard]>=0.30.0
openai>=1.0.0
+31
View File
@@ -0,0 +1,31 @@
{
"openai_api_key": "",
"presets": [
{
"id": "9b143227",
"name": "Test",
"description": "demo",
"template": "Resolve {{issue_id}} in {{repo}} as {{author}}.",
"variables": [
"issue_id",
"repo",
"author"
],
"created_at": "2026-06-26T12:38:46.654788Z",
"updated_at": "2026-06-26T12:38:46.654801Z"
},
{
"id": "ba0eb27f",
"name": "Luna Coreprojc betternews",
"description": "",
"template": "/new resolve COREPROJEC-{{CPC_ID}} in projects/betternews. Remember to always read each AGENTS and CLAUDE.md in all sub-repos ({{SUB_REPOS}}) and respect their rules. Migrations will be needed, do not apply anything to the database yet, only test locally if it compiles, then it should also work live.\n\nRead the description of the work item first please. Respect the Metadata.\n\nMake PRs in all {{SUM_REPOS}} sub-repos once completed, send their links to me, create them as Luna.",
"variables": [
"CPC_ID",
"SUB_REPOS",
"SUM_REPOS"
],
"created_at": "2026-06-26T13:35:20.177361Z",
"updated_at": "2026-06-26T13:35:20.177371Z"
}
]
}