cloudwright-ai-web 0.1.0__tar.gz
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.
- cloudwright_ai_web-0.1.0/.claude/notepad.json +5 -0
- cloudwright_ai_web-0.1.0/.gitignore +16 -0
- cloudwright_ai_web-0.1.0/CLAUDE.md +19 -0
- cloudwright_ai_web-0.1.0/PKG-INFO +38 -0
- cloudwright_ai_web-0.1.0/README.md +20 -0
- cloudwright_ai_web-0.1.0/cloudwright_web/__init__.py +11 -0
- cloudwright_ai_web-0.1.0/cloudwright_web/app.py +299 -0
- cloudwright_ai_web-0.1.0/cloudwright_web/py.typed +0 -0
- cloudwright_ai_web-0.1.0/frontend/index.html +16 -0
- cloudwright_ai_web-0.1.0/frontend/package.json +23 -0
- cloudwright_ai_web-0.1.0/frontend/src/App.tsx +446 -0
- cloudwright_ai_web-0.1.0/frontend/src/components/ArchitectureDiagram.tsx +145 -0
- cloudwright_ai_web-0.1.0/frontend/src/components/CostTable.tsx +68 -0
- cloudwright_ai_web-0.1.0/frontend/src/main.tsx +9 -0
- cloudwright_ai_web-0.1.0/frontend/tsconfig.json +21 -0
- cloudwright_ai_web-0.1.0/frontend/vite.config.ts +15 -0
- cloudwright_ai_web-0.1.0/pyproject.toml +33 -0
- cloudwright_ai_web-0.1.0/tests/__init__.py +0 -0
- cloudwright_ai_web-0.1.0/tests/test_api.py +449 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
dist/
|
|
5
|
+
build/
|
|
6
|
+
.env
|
|
7
|
+
*.db
|
|
8
|
+
!packages/core/cloudwright/data/catalog.db
|
|
9
|
+
node_modules/
|
|
10
|
+
.vite/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
|
|
15
|
+
# Codebase intelligence (auto-generated)
|
|
16
|
+
.planning/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# cloudwright-web
|
|
2
|
+
|
|
3
|
+
Web UI for Cloudwright. FastAPI backend wrapping core package + React frontend.
|
|
4
|
+
|
|
5
|
+
## Backend
|
|
6
|
+
|
|
7
|
+
`backend/app.py` — FastAPI app with endpoints for design, cost, validate, export, catalog.
|
|
8
|
+
|
|
9
|
+
## Frontend
|
|
10
|
+
|
|
11
|
+
React + TypeScript + Vite. Interactive architecture diagrams with React Flow,
|
|
12
|
+
cost tables, comparison views. Same chat experience as the CLI but visual.
|
|
13
|
+
|
|
14
|
+
## Running
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cloudwright serve # starts both backend and frontend
|
|
18
|
+
cloudwright chat --web # same thing
|
|
19
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cloudwright-ai-web
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Web UI for Cloudwright architecture intelligence
|
|
5
|
+
Project-URL: Homepage, https://github.com/xmpuspus/cloudwright
|
|
6
|
+
Project-URL: Repository, https://github.com/xmpuspus/cloudwright
|
|
7
|
+
Author: Xavier Puspus
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: architecture,cloud,fastapi,web
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: cloudwright-ai<1,>=0.1.0
|
|
15
|
+
Requires-Dist: fastapi<1,>=0.116
|
|
16
|
+
Requires-Dist: uvicorn[standard]<1,>=0.35
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# cloudwright-web
|
|
20
|
+
|
|
21
|
+
Web UI for [Cloudwright](https://github.com/xmpuspus/cloudwright) architecture intelligence. FastAPI backend with React frontend.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install cloudwright[web]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cloudwright serve # starts the web server
|
|
33
|
+
cloudwright chat --web # same thing
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The API runs at `http://localhost:8000` with endpoints for design, cost, validate, export, and catalog operations.
|
|
37
|
+
|
|
38
|
+
See the [main project README](https://github.com/xmpuspus/cloudwright) for full documentation.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# cloudwright-web
|
|
2
|
+
|
|
3
|
+
Web UI for [Cloudwright](https://github.com/xmpuspus/cloudwright) architecture intelligence. FastAPI backend with React frontend.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cloudwright[web]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cloudwright serve # starts the web server
|
|
15
|
+
cloudwright chat --web # same thing
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The API runs at `http://localhost:8000` with endpoints for design, cost, validate, export, and catalog operations.
|
|
19
|
+
|
|
20
|
+
See the [main project README](https://github.com/xmpuspus/cloudwright) for full documentation.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Cloudwright Web — FastAPI backend for architecture intelligence."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name: str):
|
|
7
|
+
if name == "app":
|
|
8
|
+
from cloudwright_web.app import app
|
|
9
|
+
|
|
10
|
+
return app
|
|
11
|
+
raise AttributeError(f"module 'cloudwright_web' has no attribute {name!r}")
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""FastAPI backend wrapping the Cloudwright core package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from cloudwright import ArchSpec, Constraints
|
|
10
|
+
from cloudwright.architect import Architect
|
|
11
|
+
from cloudwright.catalog import Catalog
|
|
12
|
+
from cloudwright.cost import CostEngine
|
|
13
|
+
from cloudwright.differ import Differ
|
|
14
|
+
from cloudwright.exporter import FORMATS, export_spec
|
|
15
|
+
from cloudwright.validator import Validator
|
|
16
|
+
from fastapi import FastAPI, HTTPException
|
|
17
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
18
|
+
from fastapi.responses import FileResponse
|
|
19
|
+
from fastapi.staticfiles import StaticFiles
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
app = FastAPI(title="Cloudwright", version="0.1.0", description="Architecture intelligence for cloud engineers")
|
|
25
|
+
|
|
26
|
+
app.add_middleware(
|
|
27
|
+
CORSMiddleware,
|
|
28
|
+
allow_origins=["http://localhost:5173", "http://localhost:3000"],
|
|
29
|
+
allow_methods=["*"],
|
|
30
|
+
allow_headers=["*"],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Lazy singletons
|
|
34
|
+
_architect: Architect | None = None
|
|
35
|
+
_catalog: Catalog | None = None
|
|
36
|
+
_cost_engine: CostEngine | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_architect() -> Architect:
|
|
40
|
+
global _architect
|
|
41
|
+
if _architect is None:
|
|
42
|
+
_architect = Architect()
|
|
43
|
+
return _architect
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_catalog() -> Catalog:
|
|
47
|
+
global _catalog
|
|
48
|
+
if _catalog is None:
|
|
49
|
+
_catalog = Catalog()
|
|
50
|
+
return _catalog
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_cost_engine() -> CostEngine:
|
|
54
|
+
global _cost_engine
|
|
55
|
+
if _cost_engine is None:
|
|
56
|
+
_cost_engine = CostEngine()
|
|
57
|
+
return _cost_engine
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- Request/Response models ---
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DesignRequest(BaseModel):
|
|
64
|
+
description: str = Field(..., min_length=5, max_length=2000)
|
|
65
|
+
provider: str = "aws"
|
|
66
|
+
region: str = "us-east-1"
|
|
67
|
+
budget_monthly: float | None = None
|
|
68
|
+
compliance: list[str] = Field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ModifyRequest(BaseModel):
|
|
72
|
+
spec: dict
|
|
73
|
+
instruction: str = Field(..., min_length=3, max_length=2000)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ValidateRequest(BaseModel):
|
|
77
|
+
spec: dict
|
|
78
|
+
compliance: list[str] = Field(default_factory=list)
|
|
79
|
+
well_architected: bool = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ExportRequest(BaseModel):
|
|
83
|
+
spec: dict
|
|
84
|
+
format: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DiffRequest(BaseModel):
|
|
88
|
+
old_spec: dict
|
|
89
|
+
new_spec: dict
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CostRequest(BaseModel):
|
|
93
|
+
spec: dict
|
|
94
|
+
compare_providers: list[str] = Field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class CatalogSearchRequest(BaseModel):
|
|
98
|
+
query: str | None = None
|
|
99
|
+
provider: str | None = None
|
|
100
|
+
vcpus: int | None = None
|
|
101
|
+
memory_gb: float | None = None
|
|
102
|
+
max_price_per_hour: float | None = None
|
|
103
|
+
limit: int = Field(default=20, ge=1, le=100)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class CatalogCompareRequest(BaseModel):
|
|
107
|
+
instance_names: list[str] = Field(..., min_length=2)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ChatMessage(BaseModel):
|
|
111
|
+
role: Literal["user", "assistant"]
|
|
112
|
+
content: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ChatRequest(BaseModel):
|
|
116
|
+
message: str = Field(..., min_length=1, max_length=2000)
|
|
117
|
+
history: list[ChatMessage] = Field(default_factory=list)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- Endpoints ---
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.get("/api/health")
|
|
124
|
+
def health():
|
|
125
|
+
try:
|
|
126
|
+
catalog = get_catalog()
|
|
127
|
+
# Quick check: can we search?
|
|
128
|
+
results = catalog.search(query="m5", limit=1)
|
|
129
|
+
return {"status": "ok", "catalog_loaded": True, "sample_count": len(results)}
|
|
130
|
+
except Exception:
|
|
131
|
+
return {"status": "ok", "catalog_loaded": False}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.post("/api/design")
|
|
135
|
+
def design(req: DesignRequest):
|
|
136
|
+
try:
|
|
137
|
+
architect = get_architect()
|
|
138
|
+
constraints = None
|
|
139
|
+
if req.budget_monthly or req.compliance:
|
|
140
|
+
constraints = Constraints(
|
|
141
|
+
budget_monthly=req.budget_monthly,
|
|
142
|
+
compliance=req.compliance,
|
|
143
|
+
)
|
|
144
|
+
spec = architect.design(req.description, constraints=constraints)
|
|
145
|
+
return {"spec": spec.model_dump(exclude_none=True), "yaml": spec.to_yaml()}
|
|
146
|
+
except Exception as e:
|
|
147
|
+
log.exception("Design endpoint failed")
|
|
148
|
+
raise HTTPException(status_code=500, detail="Internal server error") from e
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.post("/api/modify")
|
|
152
|
+
def modify(req: ModifyRequest):
|
|
153
|
+
try:
|
|
154
|
+
architect = get_architect()
|
|
155
|
+
spec = ArchSpec.model_validate(req.spec)
|
|
156
|
+
updated = architect.modify(spec, req.instruction)
|
|
157
|
+
return {"spec": updated.model_dump(exclude_none=True), "yaml": updated.to_yaml()}
|
|
158
|
+
except Exception as e:
|
|
159
|
+
log.exception("Modify endpoint failed")
|
|
160
|
+
raise HTTPException(status_code=500, detail="Internal server error") from e
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.post("/api/cost")
|
|
164
|
+
def cost(req: CostRequest):
|
|
165
|
+
try:
|
|
166
|
+
engine = get_cost_engine()
|
|
167
|
+
spec = ArchSpec.model_validate(req.spec)
|
|
168
|
+
estimate = engine.estimate(spec)
|
|
169
|
+
|
|
170
|
+
result = {"estimate": estimate.model_dump()}
|
|
171
|
+
|
|
172
|
+
if req.compare_providers:
|
|
173
|
+
architect = get_architect()
|
|
174
|
+
alternatives = architect.compare(spec, req.compare_providers)
|
|
175
|
+
result["alternatives"] = [a.model_dump(exclude_none=True) for a in alternatives]
|
|
176
|
+
|
|
177
|
+
return result
|
|
178
|
+
except Exception as e:
|
|
179
|
+
log.exception("Cost endpoint failed")
|
|
180
|
+
raise HTTPException(status_code=500, detail="Internal server error") from e
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.post("/api/validate")
|
|
184
|
+
def validate(req: ValidateRequest):
|
|
185
|
+
try:
|
|
186
|
+
validator = Validator()
|
|
187
|
+
spec = ArchSpec.model_validate(req.spec)
|
|
188
|
+
frameworks = req.compliance if req.compliance else []
|
|
189
|
+
results = validator.validate(spec, compliance=frameworks or None, well_architected=req.well_architected)
|
|
190
|
+
return {"results": [r.model_dump() for r in results]}
|
|
191
|
+
except Exception as e:
|
|
192
|
+
log.exception("Validate endpoint failed")
|
|
193
|
+
raise HTTPException(status_code=500, detail="Internal server error") from e
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.post("/api/export")
|
|
197
|
+
def export(req: ExportRequest):
|
|
198
|
+
try:
|
|
199
|
+
spec = ArchSpec.model_validate(req.spec)
|
|
200
|
+
if req.format not in FORMATS:
|
|
201
|
+
raise HTTPException(
|
|
202
|
+
status_code=400, detail=f"Unknown format: {req.format}. Supported: {', '.join(FORMATS)}"
|
|
203
|
+
)
|
|
204
|
+
content = export_spec(spec, req.format)
|
|
205
|
+
return {"content": content, "format": req.format}
|
|
206
|
+
except HTTPException:
|
|
207
|
+
raise
|
|
208
|
+
except Exception as e:
|
|
209
|
+
log.exception("Export endpoint failed")
|
|
210
|
+
raise HTTPException(status_code=500, detail="Internal server error") from e
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.post("/api/diff")
|
|
214
|
+
def diff(req: DiffRequest):
|
|
215
|
+
try:
|
|
216
|
+
differ = Differ()
|
|
217
|
+
old = ArchSpec.model_validate(req.old_spec)
|
|
218
|
+
new = ArchSpec.model_validate(req.new_spec)
|
|
219
|
+
result = differ.diff(old, new)
|
|
220
|
+
return {"diff": result.model_dump()}
|
|
221
|
+
except Exception as e:
|
|
222
|
+
log.exception("Diff endpoint failed")
|
|
223
|
+
raise HTTPException(status_code=500, detail="Internal server error") from e
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.post("/api/catalog/search")
|
|
227
|
+
def catalog_search(req: CatalogSearchRequest):
|
|
228
|
+
try:
|
|
229
|
+
catalog = get_catalog()
|
|
230
|
+
instances = catalog.search(
|
|
231
|
+
query=req.query,
|
|
232
|
+
vcpus=req.vcpus,
|
|
233
|
+
memory_gb=req.memory_gb,
|
|
234
|
+
provider=req.provider,
|
|
235
|
+
max_price_per_hour=req.max_price_per_hour,
|
|
236
|
+
limit=req.limit,
|
|
237
|
+
)
|
|
238
|
+
return {"instances": instances}
|
|
239
|
+
except Exception as e:
|
|
240
|
+
log.exception("Catalog search endpoint failed")
|
|
241
|
+
raise HTTPException(status_code=500, detail="Internal server error") from e
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.post("/api/catalog/compare")
|
|
245
|
+
def catalog_compare(req: CatalogCompareRequest):
|
|
246
|
+
try:
|
|
247
|
+
catalog = get_catalog()
|
|
248
|
+
result = catalog.compare(*req.instance_names)
|
|
249
|
+
return {"comparison": result}
|
|
250
|
+
except Exception as e:
|
|
251
|
+
log.exception("Catalog compare endpoint failed")
|
|
252
|
+
raise HTTPException(status_code=500, detail="Internal server error") from e
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.post("/api/chat")
|
|
256
|
+
def chat(req: ChatRequest):
|
|
257
|
+
try:
|
|
258
|
+
from cloudwright.architect import ConversationSession
|
|
259
|
+
|
|
260
|
+
architect = get_architect()
|
|
261
|
+
session = ConversationSession(llm=architect.llm)
|
|
262
|
+
|
|
263
|
+
# Replay history into the session
|
|
264
|
+
for msg in req.history:
|
|
265
|
+
session.history.append({"role": msg.role, "content": msg.content})
|
|
266
|
+
|
|
267
|
+
text, spec = session.send(req.message)
|
|
268
|
+
# Fallback: if session didn't extract a spec, try direct design
|
|
269
|
+
if spec is None and not req.history:
|
|
270
|
+
spec = architect.design(req.message)
|
|
271
|
+
text = f"Architecture: {spec.name}"
|
|
272
|
+
result: dict = {"reply": text, "history": session.history}
|
|
273
|
+
if spec:
|
|
274
|
+
result["spec"] = spec.model_dump(exclude_none=True)
|
|
275
|
+
result["yaml"] = spec.to_yaml()
|
|
276
|
+
return result
|
|
277
|
+
except Exception as e:
|
|
278
|
+
log.exception("Chat endpoint failed")
|
|
279
|
+
raise HTTPException(status_code=500, detail="Internal server error") from e
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# Serve frontend static files if they exist
|
|
283
|
+
_frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
|
|
284
|
+
if _frontend_dist.exists():
|
|
285
|
+
app.mount("/assets", StaticFiles(directory=str(_frontend_dist / "assets")), name="assets")
|
|
286
|
+
|
|
287
|
+
@app.get("/{path:path}")
|
|
288
|
+
def serve_frontend(path: str):
|
|
289
|
+
file_path = (_frontend_dist / path).resolve()
|
|
290
|
+
if file_path.is_relative_to(_frontend_dist.resolve()) and file_path.is_file():
|
|
291
|
+
return FileResponse(str(file_path))
|
|
292
|
+
return FileResponse(str(_frontend_dist / "index.html"))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def serve(host: str = "127.0.0.1", port: int = 8000):
|
|
296
|
+
"""Start the Cloudwright web server."""
|
|
297
|
+
import uvicorn
|
|
298
|
+
|
|
299
|
+
uvicorn.run(app, host=host, port=port)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Cloudwright — Architecture Intelligence</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cloudwright-web",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "^18.3.1",
|
|
13
|
+
"react-dom": "^18.3.1",
|
|
14
|
+
"@xyflow/react": "^12.3.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/react": "^18.3.0",
|
|
18
|
+
"@types/react-dom": "^18.3.0",
|
|
19
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
20
|
+
"typescript": "^5.5.0",
|
|
21
|
+
"vite": "^5.4.0"
|
|
22
|
+
}
|
|
23
|
+
}
|